Skip to content

Commit 89e9fcf

Browse files
SNOW-3152139: Create UDF_init_once decorator in python client (#4269)
1 parent 809b627 commit 89e9fcf

7 files changed

Lines changed: 148 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
### Snowpark Python API Updates
66

7+
#### New Features
8+
9+
- Added the `udf_init_once` decorator in `snowflake.snowpark.functions` for marking functions to be executed once during pre-fork initialization on Snowflake workers, matching the server-side `_snowflake.udf_init_once` API.
10+
711
#### Bug Fixes
812

913
- Fixed a bug where stage paths and file format names that contain single quotes were not consistently escaped when generating SQL, which could produce malformed statements. This affects `INFER_SCHEMA` (used by `DataFrameReader.csv`/`json`/`parquet`/`orc`/`avro`) and `COPY FILES` (used by `FileOperation.copy_files`).

docs/source/snowpark/functions.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,7 @@ Functions
575575
typeof
576576
udaf
577577
udf
578+
udf_init_once
578579
udtf
579580
unbase64
580581
unicode

src/snowflake/snowpark/functions.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@
158158
<BLANKLINE>
159159
"""
160160
import functools
161+
import inspect
161162
import typing
162163
from functools import reduce
163164
import json # noqa: F401 (only referenced by doctests in this module)
@@ -10329,6 +10330,64 @@ def _inner(*args, **kwargs):
1032910330
return _decorator
1033010331

1033110332

10333+
def udf_init_once(func: Callable) -> Callable:
10334+
"""Mark a function to be executed once during pre-fork initialization.
10335+
10336+
This decorator has the same signature as the server-side ``_snowflake.udf_init_once``.
10337+
It is a no-op for local invocation. Functions decorated with ``@udf_init_once`` run in
10338+
the head worker process before individual workers are forked. The initialized state
10339+
(e.g., loaded models, computed lookup tables) is shared across all workers via
10340+
Copy-On-Write.
10341+
10342+
The decorated function must:
10343+
- Accept zero arguments
10344+
- Be a callable (function, lambda, or callable object)
10345+
10346+
Multiple ``@udf_init_once`` functions are executed in the order they are defined.
10347+
10348+
Example::
10349+
10350+
from snowflake.snowpark.functions import udf_init_once
10351+
10352+
model = None
10353+
10354+
@udf_init_once
10355+
def load_model():
10356+
global model
10357+
model = 42
10358+
10359+
Use this decorator in handler files registered via
10360+
:meth:`~snowflake.snowpark.udf.UDFRegistration.register_from_file`.
10361+
On the Snowflake server the ``_snowflake.udf_init_once`` implementation
10362+
is used instead; this client-side definition provides the same API so
10363+
that handler files can be tested locally.
10364+
10365+
Args:
10366+
func: The init function to decorate. Must be callable and accept zero arguments.
10367+
10368+
Returns:
10369+
The decorated function, unchanged.
10370+
10371+
See Also:
10372+
- :func:`udf`
10373+
- :meth:`~snowflake.snowpark.udf.UDFRegistration.register_from_file`
10374+
"""
10375+
if not callable(func):
10376+
raise TypeError(
10377+
f"@udf_init_once target must be callable, got {type(func).__name__}"
10378+
)
10379+
sig = inspect.signature(func)
10380+
if len(sig.parameters) != 0:
10381+
raise TypeError(
10382+
f"@udf_init_once function must take 0 arguments, got {len(sig.parameters)}"
10383+
)
10384+
# This client-side decorator is a no-op that returns the function unchanged;
10385+
# it exists only to mirror the ``_snowflake.udf_init_once`` signature so that
10386+
# handler files import and validate cleanly when authored/tested locally. The
10387+
# actual pre-fork execution is performed server-side by ``_snowflake``.
10388+
return func
10389+
10390+
1033210391
@publicapi
1033310392
def pandas_udtf(
1033410393
handler: Optional[Callable] = None,

tests/integ/test_function.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@
167167
import functools
168168
from snowflake.connector.options import installed_pandas
169169
from snowflake.snowpark.functions import udf, vectorized
170+
from snowflake.snowpark.udf import UserDefinedFunction
170171
from snowflake.snowpark.types import (
171172
ArrayType,
172173
BooleanType,
@@ -2710,3 +2711,28 @@ def add_series(s1, s2):
27102711
)
27112712
res = df.select(add_series("a", "b").alias("result")).collect()
27122713
assert [row.RESULT for row in res] == [3, 30, 300, 15]
2714+
2715+
2716+
@pytest.mark.skipif(
2717+
"config.getoption('local_testing_mode', default=False)",
2718+
reason="UDF init_once is not supported in Local Testing",
2719+
)
2720+
def test_udf_init_once_register_from_file(session):
2721+
"""A handler file using @udf_init_once registers successfully via register_from_file.
2722+
2723+
``@udf_init_once`` is a client-side API-parity shim: it validates the target
2724+
and returns it unchanged, mirroring the ``_snowflake.udf_init_once`` signature
2725+
so handler files import cleanly both locally and on the server. Server-side
2726+
pre-fork execution of the decorated init function is not yet GA, so this test
2727+
only verifies that a handler file importing ``udf_init_once`` can be registered
2728+
via ``register_from_file``; it intentionally does not invoke the UDF to assert
2729+
the init function's effect.
2730+
"""
2731+
multiply_udf = session.udf.register_from_file(
2732+
file_path="tests/resources/test_udf_dir/test_udf_init_once_file.py",
2733+
func_name="multiply",
2734+
return_type=IntegerType(),
2735+
input_types=[IntegerType()],
2736+
)
2737+
assert isinstance(multiply_udf, UserDefinedFunction)
2738+
assert multiply_udf.name
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
try:
2+
from _snowflake import udf_init_once
3+
except ModuleNotFoundError:
4+
from snowflake.snowpark.functions import udf_init_once
5+
6+
7+
_multiplier = 1
8+
9+
10+
@udf_init_once
11+
def setup():
12+
global _multiplier
13+
_multiplier = 10
14+
15+
16+
def multiply(x):
17+
return x * _multiplier

tests/unit/scala/test_utils_suite.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
get_stage_parts,
2020
get_temp_type_for_object,
2121
get_udf_upload_prefix,
22+
is_cloud_path,
2223
is_snowflake_quoted_id_case_insensitive,
2324
is_snowflake_unquoted_suffix_case_insensitive,
2425
is_sql_select_statement,
@@ -30,7 +31,6 @@
3031
validate_object_name,
3132
warning,
3233
zip_file_or_directory_to_stream,
33-
is_cloud_path,
3434
)
3535
from tests.utils import IS_WINDOWS, TestFiles
3636

@@ -105,32 +105,32 @@ def test_calculate_checksum():
105105
if IS_WINDOWS:
106106
assert (
107107
calculate_checksum(test_files.test_udf_directory)
108-
== "7ff46b5f3765187c7355852811c92ff232feb2f7207a12f1ab6d0b921319643b"
108+
== "a72518e02fe61d2bfb6e54aa684b9af2da7845f4fb1a758c3c3974d35bf58d8f"
109109
)
110110
assert (
111111
calculate_checksum(test_files.test_udf_directory, algorithm="md5")
112-
== "f6c1984af9ece1bd68edf16ae1a7f992"
112+
== "34fa7ef3aa8ff5c19b328fcc8f601e1e"
113113
)
114114
else:
115115
assert (
116116
calculate_checksum(test_files.test_udf_directory)
117-
== "3a2607ef293801f59e7840f5be423d4a55edfe2ac732775dcfda01205df377f0"
117+
== "d472060e6d717517a3f9c7048ac43fc0c646467a6b3772f63355071a65ad8ecf"
118118
)
119119
assert (
120120
calculate_checksum(test_files.test_udf_directory, algorithm="md5")
121-
== "b72b61c8d5639fff8aa9a80278dba60f"
121+
== "322ad29c4018a1375f61b670196cf902"
122122
)
123123
# Validate that hashes are different when reading whole dir.
124124
# Using a sufficiently small chunk size so that the hashes differ.
125125
assert (
126126
calculate_checksum(test_files.test_udf_directory, chunk_size=128)
127-
== "c071de824a67c083edad45c2b18729e17c50f1b13be980140437063842ea2469"
127+
== "40d4811752a978779518082f0312f8823cb46dd84d12c68a7bb456c388f38df4"
128128
)
129129
assert (
130130
calculate_checksum(
131131
test_files.test_udf_directory, chunk_size=128, whole_file_hash=True
132132
)
133-
== "3a2607ef293801f59e7840f5be423d4a55edfe2ac732775dcfda01205df377f0"
133+
== "d472060e6d717517a3f9c7048ac43fc0c646467a6b3772f63355071a65ad8ecf"
134134
)
135135

136136

@@ -258,6 +258,7 @@ def check_zip_files_and_close_stream(input_stream, expected_files):
258258
[
259259
"test_udf_dir/",
260260
"test_udf_dir/test_another_udf_file.py",
261+
"test_udf_dir/test_udf_init_once_file.py",
261262
"test_udf_dir/test_pandas_udf_file.py",
262263
"test_udf_dir/test_udf_file.py",
263264
],
@@ -272,6 +273,7 @@ def check_zip_files_and_close_stream(input_stream, expected_files):
272273
[
273274
"test_udf_dir/",
274275
"test_udf_dir/test_another_udf_file.py",
276+
"test_udf_dir/test_udf_init_once_file.py",
275277
"test_udf_dir/test_pandas_udf_file.py",
276278
"test_udf_dir/test_udf_file.py",
277279
],
@@ -287,6 +289,7 @@ def check_zip_files_and_close_stream(input_stream, expected_files):
287289
"resources/",
288290
"resources/test_udf_dir/",
289291
"resources/test_udf_dir/test_another_udf_file.py",
292+
"resources/test_udf_dir/test_udf_init_once_file.py",
290293
"resources/test_udf_dir/test_pandas_udf_file.py",
291294
"resources/test_udf_dir/test_udf_file.py",
292295
],
@@ -386,6 +389,7 @@ def check_zip_files_and_close_stream(input_stream, expected_files):
386389
"resources/test_sp_dir/test_table_sp_file.py",
387390
"resources/test_udf_dir/",
388391
"resources/test_udf_dir/test_another_udf_file.py",
392+
"resources/test_udf_dir/test_udf_init_once_file.py",
389393
"resources/test_udf_dir/test_pandas_udf_file.py",
390394
"resources/test_udf_dir/test_udf_file.py",
391395
"resources/test_udtf_dir/",

tests/unit/test_udf_utils.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,36 @@ def test_generate_python_code_exception():
8282
assert "Source code comment could not be generated" in generated_code
8383

8484

85+
def test_udf_init_once_decorator_simple():
86+
"""Test @udf_init_once as simple decorator (same API as _snowflake.udf_init_once)."""
87+
from snowflake.snowpark.functions import udf_init_once
88+
89+
@udf_init_once
90+
def my_init():
91+
pass
92+
93+
# The client-side decorator is a no-op: it returns the function unchanged.
94+
assert my_init.__name__ == "my_init"
95+
assert callable(my_init)
96+
97+
98+
def test_udf_init_once_rejects_non_zero_arg_function():
99+
from snowflake.snowpark.functions import udf_init_once
100+
101+
with pytest.raises(TypeError, match="must take 0 arguments"):
102+
103+
@udf_init_once
104+
def bad_init(x):
105+
return x
106+
107+
108+
def test_udf_init_once_rejects_non_callable():
109+
from snowflake.snowpark.functions import udf_init_once
110+
111+
with pytest.raises(TypeError, match="must be callable"):
112+
udf_init_once("not_a_function")
113+
114+
85115
@pytest.mark.parametrize(
86116
"packages",
87117
[

0 commit comments

Comments
 (0)