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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

### Snowpark Python API Updates

#### New Features

- Added interval type support for Python UDFs and stored procedures. Use `datetime.timedelta` as the type annotation for day-time interval (`DayTimeIntervalType`) parameters and return values, and `YearMonthInterval` (a type annotation sentinel from `snowflake.snowpark.types`) for year-month interval (`YearMonthIntervalType`) parameters and return values.

## 1.54.0 (2026-07-29)

### Snowpark Python API updates
Expand Down
25 changes: 25 additions & 0 deletions src/snowflake/snowpark/_internal/analyzer/datatype_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,31 @@ def to_sql(
if isinstance(value, str) and isinstance(datatype, DayTimeIntervalType):
return f"{str_to_sql_for_day_time_interval(value, datatype)} :: {convert_sp_to_sf_type(datatype)}"

if isinstance(value, timedelta) and isinstance(datatype, DayTimeIntervalType):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can we unit test this conversion to SQL string? The math is pretty complicated so some tests would be ideal

# Serialize timedelta as an INTERVAL DAY TO SECOND literal.
# timedelta stores (days, seconds, microseconds) all non-negative after normalization.
sign = "-" if value < timedelta(0) else "+"
abs_val = abs(value)
d = abs_val.days
total_us = abs_val.seconds * 1_000_000 + abs_val.microseconds
h = total_us // 3_600_000_000
total_us %= 3_600_000_000
m = total_us // 60_000_000
total_us %= 60_000_000
s = total_us // 1_000_000
us = total_us % 1_000_000
interval_str = f"{sign}{d} {h:02d}:{m:02d}:{s:02d}.{us:06d}"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can you check if we can reuse format_day_time_interval() or format_day_time_interval_for_display() in type_utils.py?
another question I have is do we need :: {convert_sp_to_sf_type(datatype)} like we used when value is a string?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The display functions (format_day_time_interval_for_display, format_year_month_interval_for_display) go in the opposite direction — they take interval strings already returned by Snowflake and reformat them for display. Our new to_sql cases go the other way: Python native values (timedelta, int) → SQL literal. Different input type, different purpose, so they're not reusable here.

For the suffix, you are right. I have added the suffix for consistency.

return f"INTERVAL '{interval_str}' DAY TO SECOND :: {convert_sp_to_sf_type(datatype)}"

if isinstance(value, int) and isinstance(datatype, YearMonthIntervalType):
# Serialize int (total months) as an INTERVAL YEAR TO MONTH literal.
sign = "-" if value < 0 else "+"
abs_months = abs(value)
years = abs_months // 12
months = abs_months % 12
interval_str = f"{sign}{years}-{months:02d}"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

similar here, is it possible we reuse format_year_month_interval_for_display() ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

return f"INTERVAL '{interval_str}' YEAR TO MONTH :: {convert_sp_to_sf_type(datatype)}"

raise TypeError(f"Unsupported datatype {datatype}, value {value} by to_sql()")


Expand Down
6 changes: 6 additions & 0 deletions src/snowflake/snowpark/_internal/type_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,10 @@ def python_type_str_to_object(
return datetime.time
elif tp_str == "datetime":
return datetime.datetime
elif tp_str == "timedelta":
return datetime.timedelta
elif tp_str == "YearMonthInterval":
return YearMonthInterval
# This check is to handle special case when stored procs are registered using
# register_from_file where type hints are read as strings and we don't know if
# the DataFrame is a snowflake.snowpark.DataFrame or not. Here, the assumption
Expand Down Expand Up @@ -851,6 +855,8 @@ def python_type_to_snow_type(

if tp is decimal.Decimal:
return DecimalType(38, 18), False
elif tp is datetime.timedelta:
return DayTimeIntervalType(), False
elif tp in PYTHON_TO_SNOW_TYPE_MAPPINGS:
return PYTHON_TO_SNOW_TYPE_MAPPINGS[tp](), False

Expand Down
10 changes: 9 additions & 1 deletion src/snowflake/snowpark/_internal/udf_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# Copyright (c) 2012-2025 Snowflake Computing Inc. All rights reserved.
#
import collections.abc
import datetime
import inspect
import io
import os
Expand Down Expand Up @@ -57,7 +58,12 @@
validate_object_name,
warning,
)
from snowflake.snowpark.types import DataType, StructField, StructType
from snowflake.snowpark.types import (
DataType,
DayTimeIntervalType,
StructField,
StructType,
)
from snowflake.snowpark.version import VERSION
from snowflake.snowpark.context import (
_ANACONDA_SHARED_REPOSITORY,
Expand Down Expand Up @@ -1728,6 +1734,8 @@ def generate_call_python_sp_sql(
sql_args.append(session._analyzer.analyze(arg._expression, {}))
elif "system$" in sproc_name.lower():
sql_args.append(to_sql_no_cast(arg, infer_type(arg)))
elif isinstance(arg, datetime.timedelta):
sql_args.append(to_sql(arg, DayTimeIntervalType()))
else:
sql_args.append(to_sql(arg, infer_type(arg)))
return f"CALL {sproc_name}({', '.join(sql_args)})"
6 changes: 6 additions & 0 deletions src/snowflake/snowpark/mock/_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
BinaryType,
BooleanType,
DateType,
DayTimeIntervalType,
DecimalType,
DoubleType,
DecFloatType,
Expand All @@ -54,6 +55,7 @@
TimestampType,
TimeType,
VariantType,
YearMonthIntervalType,
_FractionalType,
_IntegralType,
_NumericType,
Expand Down Expand Up @@ -2221,6 +2223,10 @@ def cast_column_to(
return mock_to_array(col)
if isinstance(target_data_type, VariantType):
return mock_to_variant(col)
if isinstance(target_data_type, (DayTimeIntervalType, YearMonthIntervalType)):
res = col.copy()
res.sf_type = target_column_type
return res
return None


Expand Down
4 changes: 2 additions & 2 deletions src/snowflake/snowpark/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ class YearMonthIntervalType(_AnsiIntervalType):
Notes:
YearMonthIntervalType is currently in private preview since 1.38.0. It needs to be enabled by setting parameter `FEATURE_INTERVAL_TYPES` to `ENABLED`.

YearMonthIntervalType is currently not supported in UDFs and Stored Procedures.
Support for YearMonthIntervalType in UDFs and Stored Procedures requires the ``ENABLE_INTERVAL_TYPES_IN_UDF`` account parameter to be enabled. Use :class:`YearMonthInterval` as the Python type annotation; values are passed as the total number of months (``int``).
"""

YEAR = 0 #: Constant representing the YEAR field for interval start/end positions
Expand Down Expand Up @@ -305,7 +305,7 @@ class DayTimeIntervalType(_AnsiIntervalType):
Notes:
DayTimeIntervalType is currently in private preview since 1.38.0. It needs to be enabled by setting parameters `FEATURE_INTERVAL_TYPES` to `ENABLED`.

DayTimeIntervalType is currently not supported in UDFs and Stored Procedures.
Support for DayTimeIntervalType in UDFs and Stored Procedures requires the ``ENABLE_INTERVAL_TYPES_IN_UDF`` account parameter to be enabled. Use :class:`DayTimeInterval` or ``datetime.timedelta`` as the Python type annotation; values are passed as ``datetime.timedelta``.
"""

DAY = 0 #: Constant representing the DAY field for interval start/end positions
Expand Down
22 changes: 19 additions & 3 deletions tests/integ/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,14 @@ def test_schema(connection, local_testing_mode) -> None:
cursor.execute(
f"GRANT ALL PRIVILEGES ON SCHEMA {TEST_SCHEMA} TO ROLE PUBLIC"
)
cursor.execute(
f"ALTER SCHEMA SET DEFAULT_PYTHON_ARTIFACT_REPOSITORY = {_DEFAULT_ARTIFACT_REPOSITORY}"
)
try:
cursor.execute(
f"ALTER SCHEMA SET DEFAULT_PYTHON_ARTIFACT_REPOSITORY = {_DEFAULT_ARTIFACT_REPOSITORY}"
)
except Exception:
# Some accounts (e.g. temptest) do not support
# DEFAULT_PYTHON_ARTIFACT_REPOSITORY; skip silently.
pass
yield
cursor.execute(f"DROP SCHEMA IF EXISTS {TEST_SCHEMA}")

Expand Down Expand Up @@ -490,6 +495,17 @@ def temp_stage(session, resources_path, local_testing_mode):
Utils.drop_stage(session, tmp_stage_name)


@pytest.fixture(scope="module")
def interval_udf_enabled(session, local_testing_mode):
if local_testing_mode:
pytest.skip("ENABLE_INTERVAL_TYPES_IN_UDF not supported in local testing")
rows = session.sql(
"SHOW PARAMETERS LIKE 'ENABLE_INTERVAL_TYPES_IN_UDF' IN ACCOUNT"
).collect()
if not (bool(rows) and rows[0]["value"].upper() == "TRUE"):
pytest.skip("ENABLE_INTERVAL_TYPES_IN_UDF not enabled on this account")


@pytest.fixture(scope="function", autouse=True)
def clear_session_ast_batch_on_validate_ast(session, validate_ast):
"""
Expand Down
43 changes: 43 additions & 0 deletions tests/integ/test_stored_procedure.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,13 @@
from snowflake.snowpark.row import Row
from snowflake.snowpark.types import (
DateType,
DayTimeIntervalType,
DoubleType,
IntegerType,
StringType,
StructField,
StructType,
YearMonthIntervalType,
)
from tests.integ.session_parameters import create_session_for_test
from tests.utils import (
Expand Down Expand Up @@ -2753,3 +2755,44 @@ def multiply(session_: Session, x: int) -> int:
is_permanent=False,
)
assert sp(6) == 42


# ── Interval type stored procedures (SNOW-3746506) ───────────────────────────


@pytest.mark.skipif(IS_IN_STORED_PROC, reason="Cannot create session in SP")
def test_sproc_daytime_interval(session, interval_udf_enabled):
"""DayTimeIntervalType sproc: timedelta arg/return round-trips correctly."""

def double_interval(session: Session, d: datetime.timedelta) -> datetime.timedelta:
return d * 2

sp = session.sproc.register(
double_interval,
return_type=DayTimeIntervalType(),
input_types=[DayTimeIntervalType()],
)
result = sp(datetime.timedelta(days=3))
assert result == datetime.timedelta(days=6)


@pytest.mark.skipif(IS_IN_STORED_PROC, reason="Cannot create session in SP")
def test_sproc_yearmonth_interval(session, interval_udf_enabled):
"""YearMonthIntervalType sproc: int (total months) arg/return round-trips correctly."""

from snowflake.snowpark.types import YearMonthInterval

def promote(session: Session, m: YearMonthInterval) -> YearMonthInterval:
return m + 12 # m is int (total months) inside the sproc

sp = session.sproc.register(
promote,
return_type=YearMonthIntervalType(),
input_types=[YearMonthIntervalType()],
)
# Call via SQL to pass an explicit INTERVAL literal (infer_type(int) → NUMBER).
result = session.sql(f"CALL {sp.name}(INTERVAL '1-2' YEAR TO MONTH)").collect()[0][
0
]
# 14 months + 12 = 26 months → '+2-02'
assert result == "+2-02"
111 changes: 111 additions & 0 deletions tests/integ/test_udf.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
BinaryType,
BooleanType,
DateType,
DayTimeIntervalType,
DoubleType,
FloatType,
Geography,
Expand All @@ -81,6 +82,8 @@
TimeType,
Variant,
VariantType,
YearMonthInterval,
YearMonthIntervalType,
)
from tests.integ.session_parameters import create_session_for_test
from tests.utils import (
Expand Down Expand Up @@ -3252,3 +3255,111 @@ def test_turtles() -> str:
session._run_query(f"drop function if exists {temp_func_name}(int)")

session.sql(f"drop database {temp_database}").collect()


# ── Interval type UDFs (SNOW-3746497) ────────────────────────────────────────


def test_udf_daytime_interval_timedelta_annotation(session, interval_udf_enabled):
"""timedelta type annotation maps to DayTimeIntervalType; round-trip through a UDF."""

def add_day(d: datetime.timedelta) -> datetime.timedelta:
return d + datetime.timedelta(days=1)

f = session.udf.register(add_day)
assert f._return_type == DayTimeIntervalType()
assert f._input_types == [DayTimeIntervalType()]

result = session.sql(f"SELECT {f.name}(INTERVAL '5' DAY)").collect()
assert result[0][0] == datetime.timedelta(days=6)


def test_udf_daytime_interval_explicit_type(session, interval_udf_enabled):
"""Explicit DayTimeIntervalType input/return; multiplication round-trip."""

def triple(d: datetime.timedelta) -> datetime.timedelta:
return d * 3

f = session.udf.register(
triple,
return_type=DayTimeIntervalType(),
input_types=[DayTimeIntervalType()],
)
result = session.sql(
f"SELECT {f.name}(INTERVAL '2 12:00:00' DAY TO SECOND)"
).collect()
assert result[0][0] == datetime.timedelta(days=7, hours=12)


def test_udf_daytime_interval_null(session, interval_udf_enabled):
"""Null DayTime interval input propagates as None."""

def identity(d: datetime.timedelta) -> datetime.timedelta:
return d

f = session.udf.register(identity)
result = (
session.sql("SELECT NULL::INTERVAL DAY TO SECOND AS d").select(f("d")).collect()
)
assert result[0][0] is None


def test_udf_daytime_interval_negative(session, interval_udf_enabled):
"""Negative DayTime intervals round-trip correctly."""

def negate(d: datetime.timedelta) -> datetime.timedelta:
return -d

f = session.udf.register(negate)
result = session.sql(f"SELECT {f.name}(INTERVAL '3' DAY)").collect()
assert result[0][0] == datetime.timedelta(days=-3)


def test_udf_yearmonth_interval(session, interval_udf_enabled):
"""YearMonthInterval UDF: value arrives as int (total months) inside handler."""
# YearMonthInterval is a TypeVar used for annotation only; the coprocessor
# surfaces the value as a plain int (total months) inside the UDF body.
def add_year(m: YearMonthInterval) -> YearMonthInterval:
return m + 12

f = session.udf.register(
add_year,
return_type=YearMonthIntervalType(),
input_types=[YearMonthIntervalType()],
)
# 1yr 2mo (14 total months) + 12 = 26 months → '+2-02'
result = session.sql(f"SELECT {f.name}(INTERVAL '1-2' YEAR TO MONTH)").collect()
assert result[0][0] == "+2-02"


def test_udf_yearmonth_interval_null(session, interval_udf_enabled):
"""Null YearMonth interval input propagates as None."""

def identity(m: YearMonthInterval) -> YearMonthInterval:
return m

f = session.udf.register(
identity,
return_type=YearMonthIntervalType(),
input_types=[YearMonthIntervalType()],
)
result = (
session.sql("SELECT NULL::INTERVAL YEAR TO MONTH AS m").select(f("m")).collect()
)
assert result[0][0] is None


def test_udf_yearmonth_interval_negative(session, interval_udf_enabled):
"""YearMonth interval UDF returning a negative value displays with leading '-'."""

def negate(m: YearMonthInterval) -> YearMonthInterval:
return -m

f = session.udf.register(
negate,
return_type=YearMonthIntervalType(),
input_types=[YearMonthIntervalType()],
)
# negate(1yr 2mo) = -(14 months) → '-1-02'
result = session.sql(f"SELECT {f.name}(INTERVAL '1-2' YEAR TO MONTH)").collect()
assert result[0][0] == "-1-02"
Loading
Loading