diff --git a/docs/reference/expression-coverage-scoped.md b/docs/reference/expression-coverage-scoped.md index 3255d05b..25753fdf 100644 --- a/docs/reference/expression-coverage-scoped.md +++ b/docs/reference/expression-coverage-scoped.md @@ -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: @@ -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 | diff --git a/docs/reference/expression-coverage.json b/docs/reference/expression-coverage.json index 0f7d12e9..3d7842fb 100644 --- a/docs/reference/expression-coverage.json +++ b/docs/reference/expression-coverage.json @@ -1,7 +1,7 @@ { "stamp": { "declarations": 42, - "facts": 1558, + "facts": 1560, "operations": 324, "implementation_records": 972 }, @@ -39,8 +39,8 @@ "unknown": 0 }, "default_capable": 138, - "audited_clean": 112, - "constrained": 74, + "audited_clean": 111, + "constrained": 75, "audited_unknown": 0 } }, @@ -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": [ { @@ -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": [] @@ -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", @@ -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", diff --git a/docs/reference/expression-coverage.md b/docs/reference/expression-coverage.md index 94493fe1..4021ff87 100644 --- a/docs/reference/expression-coverage.md +++ b/docs/reference/expression-coverage.md @@ -3,7 +3,7 @@ -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). @@ -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 @@ -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. @@ -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 | diff --git a/src/mountainash/expressions/backends/capabilities/datetime/value_classes_ma.py b/src/mountainash/expressions/backends/capabilities/datetime/value_classes_ma.py index 9560d7b5..511cccc3 100644 --- a/src/mountainash/expressions/backends/capabilities/datetime/value_classes_ma.py +++ b/src/mountainash/expressions/backends/capabilities/datetime/value_classes_ma.py @@ -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: @@ -108,6 +112,19 @@ 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 @@ -115,6 +132,9 @@ def _tz_fact(backend, dialect: str | None, message: str) -> CapabilityFact: ) + 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) diff --git a/src/mountainash/expressions/backends/expression_systems/ibis/extensions_mountainash/expsys_ib_ext_ma_scalar_datetime.py b/src/mountainash/expressions/backends/expression_systems/ibis/extensions_mountainash/expsys_ib_ext_ma_scalar_datetime.py index 6708cc7a..498b39f9 100644 --- a/src/mountainash/expressions/backends/expression_systems/ibis/extensions_mountainash/expsys_ib_ext_ma_scalar_datetime.py +++ b/src/mountainash/expressions/backends/expression_systems/ibis/extensions_mountainash/expsys_ib_ext_ma_scalar_datetime.py @@ -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 @@ -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 "" duration. @@ -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 diff --git a/src/mountainash/expressions/backends/expression_systems/narwhals/extensions_mountainash/expsys_nw_ext_ma_scalar_datetime.py b/src/mountainash/expressions/backends/expression_systems/narwhals/extensions_mountainash/expsys_nw_ext_ma_scalar_datetime.py index cb03b425..d7242812 100644 --- a/src/mountainash/expressions/backends/expression_systems/narwhals/extensions_mountainash/expsys_nw_ext_ma_scalar_datetime.py +++ b/src/mountainash/expressions/backends/expression_systems/narwhals/extensions_mountainash/expsys_nw_ext_ma_scalar_datetime.py @@ -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 @@ -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 diff --git a/src/mountainash/expressions/backends/expression_systems/polars/extensions_mountainash/expsys_pl_ext_ma_scalar_datetime.py b/src/mountainash/expressions/backends/expression_systems/polars/extensions_mountainash/expsys_pl_ext_ma_scalar_datetime.py index 20ca38b8..c962c667 100644 --- a/src/mountainash/expressions/backends/expression_systems/polars/extensions_mountainash/expsys_pl_ext_ma_scalar_datetime.py +++ b/src/mountainash/expressions/backends/expression_systems/polars/extensions_mountainash/expsys_pl_ext_ma_scalar_datetime.py @@ -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 @@ -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 diff --git a/src/mountainash/expressions/core/expression_api/api_builders/extensions_mountainash/api_bldr_ext_ma_scalar_datetime.py b/src/mountainash/expressions/core/expression_api/api_builders/extensions_mountainash/api_bldr_ext_ma_scalar_datetime.py index 5fa7ba38..d54c389f 100644 --- a/src/mountainash/expressions/core/expression_api/api_builders/extensions_mountainash/api_bldr_ext_ma_scalar_datetime.py +++ b/src/mountainash/expressions/core/expression_api/api_builders/extensions_mountainash/api_bldr_ext_ma_scalar_datetime.py @@ -344,20 +344,23 @@ def is_dst( Substrait: extract_boolean (IS_DST) Args: - timezone: IANA timezone name (e.g., "America/New_York"). + timezone: IANA timezone name (e.g., "America/New_York"). Required. Returns: New ExpressionAPI with is_dst node. """ - options = {} - if timezone is not None: - from ._ma_option_domains import validate_open_value - from mountainash.core.capabilities.schema import ValueClass + if timezone is None: + from mountainash.core.errors import InvalidOptionValueError - timezone = validate_open_value( - ValueClass.IANA_TIMEZONE, "timezone", timezone, "is_dst" - ) - options["timezone"] = timezone + raise InvalidOptionValueError("is_dst requires a timezone") + + from ._ma_option_domains import validate_open_value + from mountainash.core.capabilities.schema import ValueClass + + timezone = validate_open_value( + ValueClass.IANA_TIMEZONE, "timezone", timezone, "is_dst" + ) + options = {"timezone": timezone} node = ScalarFunctionNode( function_key=FKEY_MOUNTAINASH_SCALAR_DATETIME.IS_DST, arguments=[self._node], diff --git a/src/mountainash/expressions/core/expression_protocols/expression_systems/extensions_mountainash/prtcl_expsys_ext_ma_scalar_datetime.py b/src/mountainash/expressions/core/expression_protocols/expression_systems/extensions_mountainash/prtcl_expsys_ext_ma_scalar_datetime.py index eccb772a..d386d39f 100644 --- a/src/mountainash/expressions/core/expression_protocols/expression_systems/extensions_mountainash/prtcl_expsys_ext_ma_scalar_datetime.py +++ b/src/mountainash/expressions/core/expression_protocols/expression_systems/extensions_mountainash/prtcl_expsys_ext_ma_scalar_datetime.py @@ -19,7 +19,7 @@ from __future__ import annotations -from typing import Optional, Protocol +from typing import Protocol from mountainash.core.types import ExpressionT @@ -187,7 +187,7 @@ def is_dst( self, x: ExpressionT, /, - timezone: Optional[str] = None, + timezone: str, ) -> ExpressionT: """Check if DST is observed at this time.""" ... diff --git a/tests/_spine_expectation_census.md b/tests/_spine_expectation_census.md index fc6311aa..addec6fe 100644 --- a/tests/_spine_expectation_census.md +++ b/tests/_spine_expectation_census.md @@ -113,7 +113,7 @@ Buckets: `migrated` (derivable from the spine today), `retained` (a LITERAL_ONLY | tests/expressions/cross_backend/test_parameter_sensitivity.py:424 | static-marker | UNRESOLVED | narwhals | UNRESOLVED | None | spine-derived id-keyed divergence mark via xfail_divergence('NW-STR-18') — migrated | | tests/expressions/cross_backend/test_regex_contains_refactor.py:29 | static-marker | UNRESOLVED | UNRESOLVED | UNRESOLVED | None | spine-derived id-keyed divergence mark via xfail_divergence('MA-STR-01') — migrated | | tests/expressions/cross_backend/test_relation_with_row_index.py:32 | static-marker | UNRESOLVED | UNRESOLVED | UNRESOLVED | None | spine-derived id-keyed divergence mark via xfail_divergence('NW-REL-01') — migrated | -| tests/expressions/cross_backend/test_remaining_ops_results.py:188 | static-marker | UNRESOLVED | UNRESOLVED | UNRESOLVED | None | spine-derived id-keyed divergence mark via xfail_divergence('IB-REL-06') — migrated | +| tests/expressions/cross_backend/test_remaining_ops_results.py:189 | static-marker | UNRESOLVED | UNRESOLVED | UNRESOLVED | None | spine-derived id-keyed divergence mark via xfail_divergence('IB-REL-06') — migrated | | tests/expressions/cross_backend/test_string.py:257 | static-marker | UNRESOLVED | UNRESOLVED | UNRESOLVED | None | spine-derived id-keyed divergence mark via xfail_divergence('NW-STR-19') — migrated | | tests/expressions/cross_backend/test_string.py:882 | static-marker | UNRESOLVED | polars | UNRESOLVED | None | spine-derived id-keyed divergence mark via xfail_divergence('MA-STR-03') — migrated | | tests/expressions/cross_backend/test_string_extension_results.py:12 | static-marker | UNRESOLVED | UNRESOLVED | UNRESOLVED | None | spine-derived id-keyed divergence mark via xfail_divergence('NW-STR-15') — migrated | diff --git a/tests/core/_smoke_helpers.py b/tests/core/_smoke_helpers.py index d93dd76a..0d6032bb 100644 --- a/tests/core/_smoke_helpers.py +++ b/tests/core/_smoke_helpers.py @@ -44,6 +44,7 @@ def _init_smoke_overrides() -> dict[Enum, tuple[list[Any], dict[str, Any]]]: FKEY_SUBSTRAIT_CAST.CAST: ([ma.col("a")], {"dtype": "str"}), FKEY_SUBSTRAIT_SCALAR_DATETIME.ASSUME_TIMEZONE: ([ma.col("a"), "UTC"], {}), FKEY_MOUNTAINASH_SCALAR_DATETIME.TO_TIMEZONE: ([ma.col("a"), "UTC"], {}), + FKEY_MOUNTAINASH_SCALAR_DATETIME.IS_DST: ([ma.col("a"), "UTC"], {}), FKEY_SUBSTRAIT_SCALAR_DATETIME.LOCAL_TIMESTAMP: ([ma.col("a"), "UTC"], {}), } diff --git a/tests/expressions/argument_types/option_disposition.py b/tests/expressions/argument_types/option_disposition.py index 820e2427..15ffb649 100644 --- a/tests/expressions/argument_types/option_disposition.py +++ b/tests/expressions/argument_types/option_disposition.py @@ -280,6 +280,7 @@ class InvalidOptionRejection(NamedTuple): # Datetime open-value options. ("assume_timezone", "timezone"): ("datetime",), ("to_timezone", "timezone"): ("datetime",), + ("is_dst", "timezone"): ("datetime",), ("local_timestamp", "timezone"): ("datetime",), ("offset_by", "offset"): ("datetime",), ("strftime", "format"): ("datetime",), @@ -323,6 +324,7 @@ class InvalidOptionRejection(NamedTuple): _MA_OPTION_VALUE_DOMAINS: dict[tuple[str, str], tuple[str, ...]] = { ("assume_timezone", "timezone"): ("UTC", "Australia/Sydney", "America/New_York"), ("to_timezone", "timezone"): ("UTC", "Australia/Sydney", "America/New_York"), + ("is_dst", "timezone"): ("UTC", "Australia/Sydney", "America/New_York"), ("local_timestamp", "timezone"): ("UTC", "Australia/Sydney", "America/New_York"), ("offset_by", "offset"): ("1d", "-3mo", "2h30m"), ("strftime", "format"): ("%Y-%m-%d", "%H:%M:%S"), diff --git a/tests/expressions/argument_types/test_arg_types_datetime.py b/tests/expressions/argument_types/test_arg_types_datetime.py index 6b0939e1..e4c9c70f 100644 --- a/tests/expressions/argument_types/test_arg_types_datetime.py +++ b/tests/expressions/argument_types/test_arg_types_datetime.py @@ -1245,6 +1245,132 @@ def test_to_timezone_invalid_option_rejected_at_build_time( rejection.build_expr() +# 4a. is_dst +_IS_DST_DOMAIN = ("UTC", "Australia/Sydney", "America/New_York") +_IS_DST_DATA = {"ts": [datetime(2026, 7, 21, 13, 37, 45, tzinfo=timezone.utc)]} + + +def _is_dst_expr(tz: str): + return ma.col("ts").dt.is_dst(tz) + + +def _is_dst_ref_expr(tz: str): + # July 21 2026: America/New_York is in DST (True); UTC and Australia/Sydney + # (southern-hemisphere winter) are both not (False). Contrast against + # America/New_York unless tz already is it, guaranteeing a differing result. + ref_tz = "UTC" if tz == "America/New_York" else "America/New_York" + return _is_dst_expr(ref_tz) + + +def _is_dst_disposition(backend: str) -> str: + return "declared_unsupported" if backend == "ibis" else "honored" + + +def _is_dst_backing_mode(backend: str) -> str: + return "class" if backend == "ibis" else "absence" + + +def _is_dst_reason(backend: str) -> str: + if backend == "ibis": + return ( + "is_dst is not supported on ibis -- ibis has no DST/timezone-offset " + "primitive to build on" + ) + return "native backend honors is_dst" + + +def _is_dst_probe(tz: str, backend: str) -> OptionSpec: + if _is_dst_disposition(backend) == "honored": + return OptionSpec( + FK_MA_DT.IS_DST, + "timezone", + tz, + "datetime", + lambda t=tz: _is_dst_expr(t), + lambda t=tz: _is_dst_ref_expr(t), + _IS_DST_DATA, + expected_discriminates=True, + ) + return OptionSpec( + FK_MA_DT.IS_DST, + "timezone", + tz, + "datetime", + lambda t=tz: _is_dst_expr(t), + lambda t=tz: _is_dst_expr(t), + _IS_DST_DATA, + expected_discriminates=True, + ) + + +OPTION_DISPOSITIONS.extend( + OptionCell( + FK_MA_DT.IS_DST, + _MA_DT_PROTOCOL, + "is_dst", + "timezone", + backend, + tz, + "datetime", + _is_dst_disposition(backend), + _is_dst_reason(backend), + _is_dst_backing_mode(backend), + ) + for backend in ALL_BACKENDS + for tz in _IS_DST_DOMAIN +) + +REGISTERED_OPTION_PROBES.extend( + OptionProbeRegistration( + _is_dst_probe(tz, backend), + backend, + _is_dst_disposition(backend), + BackendCapabilityError + if _is_dst_disposition(backend) == "declared_unsupported" + else None, + ) + for backend in ALL_BACKENDS + for tz in _IS_DST_DOMAIN +) + +_IS_DST_INVALID_REJECTIONS = [ + InvalidOptionRejection( + FK_MA_DT.IS_DST, + _MA_DT_PROTOCOL, + "is_dst", + "timezone", + INVALID_OPTION_VALUE, + "datetime", + lambda: _is_dst_expr(INVALID_OPTION_VALUE), + ) +] +REGISTERED_INVALID_OPTION_REJECTIONS.extend(_IS_DST_INVALID_REJECTIONS) +OPTION_DISPOSITIONS.extend( + OptionCell( + rejection.fkey, + rejection.protocol, + rejection.op, + rejection.param, + backend, + rejection.value, + rejection.dtype, + "invalid", + "canonical build-time rejection sentinel; invalid strings are unbounded", + "absence", + ) + for rejection in _IS_DST_INVALID_REJECTIONS + for backend in ALL_BACKENDS +) + + +@pytest.mark.parametrize("rejection", _IS_DST_INVALID_REJECTIONS) +def test_is_dst_invalid_option_rejected_at_build_time( + rejection: InvalidOptionRejection, +) -> None: + with pytest.raises(InvalidOptionValueError): + rejection.build_expr() + + # 5. local_timestamp _LOCAL_TS_DOMAIN = ("UTC", "Australia/Sydney", "America/New_York") @@ -2142,6 +2268,11 @@ def test_extract_boolean_timezone_invalid_option_rejected_at_build_time( rejection.build_expr() +def test_is_dst_requires_timezone() -> None: + with pytest.raises(InvalidOptionValueError): + ma.col("ts").dt.is_dst() + + TESTED_OPTION_PARAMS: list[tuple] = [] TESTED_OPTION_PARAMS.extend( ( @@ -2177,6 +2308,12 @@ def test_extract_boolean_timezone_invalid_option_rejected_at_build_time( "timezone", param_taxonomy(_MA_DT_PROTOCOL, "to_timezone", "timezone"), ), + ( + _MA_DT_PROTOCOL, + "is_dst", + "timezone", + param_taxonomy(_MA_DT_PROTOCOL, "is_dst", "timezone"), + ), ( _SUBSTRAIT_DT_PROTOCOL, "local_timestamp", diff --git a/tests/expressions/argument_types/test_coverage_guard.py b/tests/expressions/argument_types/test_coverage_guard.py index d7a1d9b4..e18df9f3 100644 --- a/tests/expressions/argument_types/test_coverage_guard.py +++ b/tests/expressions/argument_types/test_coverage_guard.py @@ -834,18 +834,6 @@ def test_collect_option_param_taxonomy_rejects_conflicting_duplicates(monkeypatc } } ) -# 6 free-string (open-value) params: the op is wired and the option flows, but a clean -# cross-backend disposition needs an open-value option pattern (no finite domain). -_KNOWN_UNTESTED_OPTION_PARAMS[ - ("MountainAshScalarDatetimeExpressionSystemProtocol", "is_dst", "timezone") -] = KnownGap( - gap_kind=GapKind.UNTESTED_OPTION, - reason=( - "is_dst is a placeholder stub (constant False on all backends); " - "timezone option non-functional — see backlog: is-dst-placeholder-implementation" - ), - since="2026-07-25", -) for _case_sensitivity_op in ( "contains", diff --git a/tests/expressions/ast/test_ast_scalar_datetime.py b/tests/expressions/ast/test_ast_scalar_datetime.py index 48acd3fe..5fbe8368 100644 --- a/tests/expressions/ast/test_ast_scalar_datetime.py +++ b/tests/expressions/ast/test_ast_scalar_datetime.py @@ -45,7 +45,7 @@ def test_is_leap_year(self): assert len(node.arguments) == 1 def test_is_dst(self): - expr = ma.col("ts").dt.is_dst() + expr = ma.col("ts").dt.is_dst("America/New_York") node = expr._node assert isinstance(node, ScalarFunctionNode) assert node.function_key == FKEY_MOUNTAINASH_SCALAR_DATETIME.IS_DST diff --git a/tests/expressions/cross_backend/test_datetime_is_dst.py b/tests/expressions/cross_backend/test_datetime_is_dst.py new file mode 100644 index 00000000..8da42260 --- /dev/null +++ b/tests/expressions/cross_backend/test_datetime_is_dst.py @@ -0,0 +1,79 @@ +"""Cross-backend results for dt.is_dst (item 65). + +Was a public API-builder method returning a non-functional placeholder +(constant False on all backends). See spec 2026-08-15-is-dst-real-implementation-design.md. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +import mountainash as ma +from mountainash.core.capabilities import load_all_capability_declarations +from mountainash.core.types import BackendCapabilityError + +load_all_capability_declarations() + +TEMPORAL_BACKENDS = [ + "polars", + "polars-lazy", + "narwhals-polars", + "narwhals-pandas", + "ibis-duckdb", + "ibis-polars", + "ibis-sqlite", +] + +# ibis has no timezone/DST primitive at all -- declared UNSUPPORTED. +HONORING_BACKENDS = [b for b in TEMPORAL_BACKENDS if not b.startswith("ibis")] +IBIS_BACKENDS = [b for b in TEMPORAL_BACKENDS if b.startswith("ibis")] + +NY = "America/New_York" +SYDNEY = "Australia/Sydney" + +# 12:00 UTC in January and July. America/New_York: EST (UTC-5) in January, +# EDT (UTC-4, DST) in July. Australia/Sydney is the southern-hemisphere +# mirror: AEST (standard) in July, AEDT (DST) in January. +DST_DATA = { + "x": [ + datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc), + datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc), + ] +} + + +@pytest.mark.cross_backend +@pytest.mark.parametrize("backend_name", HONORING_BACKENDS) +class TestIsDstNorthernHemisphere: + def test_dst_in_summer_not_in_winter( + self, backend_name, backend_factory, collect_expr + ): + df = backend_factory.create(DST_DATA, backend_name) + actual = collect_expr(df, ma.col("x").dt.is_dst(NY)) + assert actual == [False, True] + + +@pytest.mark.cross_backend +@pytest.mark.parametrize("backend_name", HONORING_BACKENDS) +class TestIsDstSouthernHemisphere: + def test_dst_in_january_not_in_july( + self, backend_name, backend_factory, collect_expr + ): + df = backend_factory.create(DST_DATA, backend_name) + actual = collect_expr(df, ma.col("x").dt.is_dst(SYDNEY)) + assert actual == [True, False] + + +@pytest.mark.cross_backend +@pytest.mark.parametrize("backend_name", IBIS_BACKENDS) +class TestIsDstIbisGate: + def test_raises_capability_error( + self, backend_name, backend_factory, collect_expr + ): + """Ibis has no timezone/DST primitive; the gate raises before dispatch.""" + df = backend_factory.create(DST_DATA, backend_name) + expr = ma.col("x").dt.is_dst(NY) + with pytest.raises(BackendCapabilityError, match="is_dst"): + collect_expr(df, expr) diff --git a/tests/expressions/cross_backend/test_datetime_unit_dispatch.py b/tests/expressions/cross_backend/test_datetime_unit_dispatch.py index 208f1199..39495feb 100644 --- a/tests/expressions/cross_backend/test_datetime_unit_dispatch.py +++ b/tests/expressions/cross_backend/test_datetime_unit_dispatch.py @@ -241,9 +241,9 @@ def test_validate_open_offset_rejects_garbage(): def test_is_dst_builder_validation(): - # no-arg must work without raising - expr_no_arg = ma.col("ts").dt.is_dst() - assert expr_no_arg is not None + # no-arg now raises: timezone is required (see test_is_dst_requires_timezone) + with pytest.raises(InvalidOptionValueError): + ma.col("ts").dt.is_dst() # valid IANA timezone expr_valid = ma.col("ts").dt.is_dst("Australia/Sydney") assert expr_valid is not None diff --git a/tests/expressions/cross_backend/test_remaining_ops_results.py b/tests/expressions/cross_backend/test_remaining_ops_results.py index 61bf175f..75600f7c 100644 --- a/tests/expressions/cross_backend/test_remaining_ops_results.py +++ b/tests/expressions/cross_backend/test_remaining_ops_results.py @@ -3,8 +3,9 @@ Tests relative datetime ops (older_than, newer_than), rfloor_divide, and null aggregates (null_count, has_nulls). -is_dst is excluded — it currently fails at the AST level -(ScalarFunctionNode options=None validation error). +is_dst has its own dedicated coverage in test_datetime_is_dst.py (item 65) +and in test_arg_types_datetime.py/test_datetime_unit_dispatch.py for +build-time validation. """ from __future__ import annotations