spark-test commented on code in PR #59978:
URL: https://github.com/apache/spark/pull/59978#discussion_r4466812337


##########
python/pyspark/sql/transpile.py:
##########
@@ -203,6 +203,9 @@ def _is_definitely_basic_type(node: ast.AST) -> bool:
             return _is_definitely_basic_type(operand)
         case ast.Name():
             return True
+        case ast.Call(func=ast.Name(id="len"), args=[_], keywords=[]):

Review Comment:
   **Blocking (P1):** Making `len()` definitely basic lets `and`/`or` chains 
over `len()` comparisons lower, which the base branch refused. Each later 
operand keeps its `len()` NULL guard (`CASE WHEN isnull(s) THEN 
raise_error(...) ELSE length(s) END`), and the lowering relies on Catalyst's 
And/Or to short-circuit past it. Catalyst gives no such guarantee. With the 
default `spark.sql.subexpressionElimination.skipForShortcutExpr=false`, 
subexpression elimination treats every And/Or operand as always evaluated. It 
finds `isnull(<len guard>)` in both comparison guards and evaluates it for 
every row before the projection runs. On Spark 4.3.0, this branch's option for 
`def f(s): return s is not None and len(s) > 3 and len(s) < 10` failed the 
query over a string column with a NULL row, raising `[USER_RAISED_EXCEPTION] 
Python UDF transpiler: cannot call len() on NULL`, where Python returns False. 
A single comparison, or the same chain with subexpression elimination disabled, 
returns Python'
 s values. The same failure occurs for `x > 0 and len(s) > 3 and len(s) < 10`, 
for `s is None or len(s) < 3 or len(s) > 10`, for the chain used as an `if` 
test, and for two calls such as `s is not None and len(s) > 3` and `s is not 
None and len(s) < 10` in one select.
   
   **Recommended change:** In the `and`/`or` lowering of `_convert_chunk`, 
refuse the chain, so the variant falls back to interpreted Python, when an 
operand after the first still emits a `len()` NULL guard after narrowing. Count 
the `len()` guards the lowering emits and compare the count before and after 
converting each later operand. Add a UDF transpiler test that runs `s is not 
None and len(s) > 3 and len(s) < 10` over a string column holding NULL and 
asserts interpreted Python's results.
   
   **Why this works:** Python evaluates only the first operand of an `and`/`or` 
unconditionally. Keeping raising `len()` guards out of the later operands 
leaves nothing that Catalyst can evaluate ahead of the short-circuit, whatever 
subexpression elimination shares within a chain or across calls.
   
   **Scope:** Keep `len()` NULL guards out of `and`/`or` operands after the 
first.
   
   **Compatibility:** Chains whose later operands carry no `len()` guard lower 
as before, and `len()` comparisons outside `and`/`or` keep their guard.
   
   **Risks:** Chains whose later operands keep a `len()` guard, such as `x > 0 
and len(s) > 3`, run as interpreted Python, as on the base branch. Narrowing 
later operands by earlier None checks is what keeps `s is not None and ...` 
chains lowered.
   
   **Constraints:** Count only `len()` guards that remain after narrowing, so a 
chain whose earlier None checks prove the parameter non-null still lowers. Run 
the regression test where subexpression elimination applies: a 
whole-stage-codegen projection with default settings.
   
   **Exclusions:**
   - Guards that the base branch already places in `and`/`or` operands, on 
ordering comparisons over parameters, are left as they are.
   
   **Success:** `s is not None and len(s) > 3 and len(s) < 10`, `x > 0 and 
len(s) > 3 and len(s) < 10`, and `s is None or len(s) < 3 or len(s) > 10` 
return interpreted Python's results on NULL rows under default subexpression 
elimination, lowered where narrowing removes every later `len()` guard and 
interpreted otherwise. No lowered `and`/`or` operand after the first contains a 
`len()` NULL guard.
   
   **Verification:**
   - **Regression:** Over a string column holding NULL, with transpilation and 
ANSI on and default subexpression elimination in a whole-stage-codegen 
projection, the three chains above, and the two calls `s is not None and len(s) 
> 3` and `s is not None and len(s) < 10` selected together, return interpreted 
Python's values.
   - **Behavior:** Constructing `x > 0 and len(s) > 3` produces no transpiled 
option.



##########
python/pyspark/sql/transpile.py:
##########
@@ -256,6 +259,46 @@ def _truthiness_col(cat: Optional[str], c: Column) -> 
Optional[Column]:
     return None
 
 
+def _null_narrowed(params: List[str], test_node: ast.AST) -> Tuple[frozenset, 
frozenset]:
+    """Return ``(body_nonnull, else_nonnull)`` — frozensets of parameter names
+    that are proven non-null in the true branch and the false branch
+    respectively of an ``if``/ternary with this test.
+
+    Recognised patterns (``p`` must be in ``params``):
+      ``p is not None``          → body: {p}
+      ``p is None``              → else: {p}
+      ``None is not p``          → body: {p}
+      ``None is p``              → else: {p}
+      ``A and B and …``          → body: union of each clause's body set
+    All other nodes yield empty sets (conservative; no narrowing assumed).
+    """
+    match test_node:
+        case (
+            ast.Compare(
+                left=ast.Name(id=name),
+                ops=[op],
+                comparators=[ast.Constant(value=None)],
+            )
+            | ast.Compare(
+                left=ast.Constant(value=None),
+                ops=[op],
+                comparators=[ast.Name(id=name)],
+            )
+        ) if name in params:
+            if isinstance(op, ast.IsNot):
+                return frozenset({name}), frozenset()
+            if isinstance(op, ast.Is):
+                return frozenset(), frozenset({name})
+        case ast.BoolOp(op=ast.And(), values=values):

Review Comment:
   **Non-blocking (P2):** Only `and` is handled here, and `_null_narrowed` 
returns nothing for an `or`. So `if a is None or b is None: return None` / 
`return a < b`, which this change newly lowers, still guards `a < b` with `CASE 
WHEN isnull(a) OR isnull(b) THEN raise_error`, and emits the NULL-guard warning 
once per lowered variant (five for an untyped two-parameter UDF). The 
`and`/`or` lowering converts every operand with the outer non-null set, so `x 
is not None and x > 0` keeps the guard on `x > 0`. `(x + 1 > 0) if x is not 
None else None` keeps it too, because arithmetic over a narrowed parameter is 
not treated as non-null. Each of these already excludes NULL, so it still has 
the problem this change fixes for the plain ternary.
   
   **Recommended change:** Extend null narrowing in three ways. First, have 
`_null_narrowed` also return false-branch narrowing for an `or`-chain of `is 
None` checks (their union). Second, when lowering an `and`, convert each later 
operand with the parameters that earlier `is not None` operands prove non-null; 
for an `or`, use those that earlier `is None` operands prove. Restore the outer 
set afterwards. Third, have `_is_nonnull_node` treat `+`, `-`, `*`, `%`, and 
unary minus over non-null operands as non-null. Add UDF transpiler tests for 
each idiom asserting no raise_error, no advisory, Python-matching results, and 
a lowered plan.
   
   **Why this works:** Each extension proves non-null only where Python itself 
never evaluates the operand on None. Under SQL three-valued logic, 
`isnotnull(x) AND x > 0` and `isnull(x) OR x > 0` return Python's results 
whatever the evaluation order. Arithmetic over non-null operands either returns 
a non-null value or raises under ANSI.
   
   **Scope:** Extend null narrowing to `or`-chains of `is None` checks, to 
short-circuit operands, and to arithmetic over narrowed parameters.
   
   **Compatibility:** Narrowing for the if/ternary shapes this change already 
recognizes is unchanged.
   
   **Risks:** Plans change for these idioms. Their operands no longer contain 
raise_error, so filter splitting may place them more freely; that is correct, 
because they can no longer raise.
   
   **Constraints:** Narrow an operand only by operands that precede it in the 
same `and`/`or`, and restore the outer set afterwards, as the if/ternary arms 
do. Keep the docstring and comments of `_null_narrowed` ASCII.
   
   **Exclusions:**
   - Short-circuit protection that comes from a condition other than a None 
check, such as `x > 0 and len(s) > 3`, is not narrowed; the fix that keeps 
`len()` guards out of later `and`/`or` operands covers that case.
   
   **Success:** `return x is not None and x > 0`, `if a is None or b is None: 
return None` / `return a < b`, their ternary twins, `(x + 1 > 0) if x is not 
None else None`, and `s is not None and len(s) > 3 and len(s) < 10` all lower 
with no raise_error and emit no NULL-guard advisory. Comparisons whose operands 
are not proven non-null keep the guard.
   
   **Verification:**
   - **Behavior:** For each idiom, the optimized plan has no raise_error, the 
JVM keeps the lowered option, no advisory is recorded, and the results match 
interpreted Python on NULL and non-NULL rows.
   - **Regression:** Cases that prove nothing keep the guard and still raise on 
NULL: tests that `or` together `is not None` checks, the true branch of an `or` 
of `is None` checks, and operands that come before the guarding check.



##########
python/pyspark/sql/transpile.py:
##########
@@ -938,7 +1017,20 @@ def _transpile_from_ast(
         # Category inference depends on the per-variant assumptions above. 
Cache
         # each AST node only for this lowering so recursive conversion stays 
linear.
         self._category_cache = {}
+        # Reset per-transpilation null-narrowing state.
+        self._nonnull_params = frozenset()
+        self._null_guard_emitted = False
         function_body = function_ast.body
+        # Normalize `if <test>: return y\nreturn body` (2-stmt early-return)
+        # into a single if/else so null narrowing can apply to the main body.
+        match function_body:
+            case [
+                ast.If(test=test, body=[ast.Return() as early_ret], orelse=[]) 
as if_node,

Review Comment:
   **Blocking (P1):** This pattern constrains only the statement shape, so any 
`if <test>: return a` / `return b` body is now lowered, not just the null-guard 
idiom it exists for. A test that is not a None check gets no narrowing benefit, 
yet the body stops running as interpreted Python, as it did on the base branch, 
and picks up the lowered semantics instead. For example, `def f(x): if x > 0: 
return 1` / `return 0` (LongType) over a DOUBLE column keeps the float option, 
where Spark orders NaN above every value: on Spark 4.3.0, this branch's float 
option returned `[1, 1, 0]` for `[NaN, 2.0, -3.0]`, where Python returns `[0, 
1, 0]`. Likewise, `if x > 0: return x + y` / `return 0` with `y` NULL returns 
NULL where Python raises TypeError. Results therefore change silently for 
inputs that were handled correctly before this change.
   
   **Recommended change:** Apply the early-return rewrite only when 
`_null_narrowed` proves some parameter non-null in either branch of the test. 
Other two-statement bodies keep failing the one-statement check and run as 
interpreted Python, as on the base branch. Add UDF transpiler regression tests 
for a non-None-test early return over a double column holding NaN, and for 
unguarded arithmetic with a NULL operand.
   
   **Why this works:** Gating the rewrite on the narrowing result confines it 
to the null-guard idiom, the only input that needs it. Bodies whose test proves 
nothing non-null keep the base branch's interpreted results.
   
   **Scope:** Gate the two-statement early-return rewrite on null narrowing.
   
   **Compatibility:** Null-guard early returns, including those narrowed 
through `and`-chains, keep lowering exactly as they do now.
   
   **Risks:** Early returns whose test is not a None check run as interpreted 
Python again, as on the base branch. That gives up the lowering this change 
added for them; it can be relaxed later once the documented differences are 
handled.
   
   **Constraints:** Use `_null_narrowed`'s result itself as the gate, so that 
any extension of narrowing (for example to `or`-chains of `is None` checks) 
automatically extends which early returns are rewritten.
   
   **Exclusions:**
   - The NaN-ordering and NULL-arithmetic differences that the null-guard idiom 
itself inherits are left to the author's decision on that idiom.
   
   **Success:** `if x > 0: return 1` / `return 0` produces no transpiled 
options and returns Python's result on a double NaN row. `if x is None: return 
None` / `return x > 0` still lowers with no raise_error in its main body.
   
   **Verification:**
   - **Regression:** A two-statement early return whose test is not a None 
check falls back to interpreted Python and returns Python's results for a NaN 
input and for a NULL operand of unguarded arithmetic.
   - **Behavior:** Null-guard early returns keep lowering without a guard, and 
the JVM keeps the lowered option.



##########
python/pyspark/sql/transpile.py:
##########
@@ -991,6 +1083,15 @@ def _transpile_from_ast(
                     "interpreted Python"
                 )
         converted = self._convert_chunk(params, function_body[0])
+        if self._null_guard_emitted:
+            warnings.warn(

Review Comment:
   **Non-blocking (P2):** This `warnings.warn` runs inside `_transpile_func`'s 
per-variant `try ... except Exception`, after the variant's Column is built, 
and `UserWarning` is an `Exception`. Under a filter that escalates it, the 
built option is recorded as an error and dropped. On Spark 4.3.0 with this 
branch's transpile.py, constructing `UserDefinedFunction(unguarded_gt, 
BooleanType())` for `def unguarded_gt(x): return x > 0` under 
`warnings.simplefilter('error')` raised `Exception transpiling UDF ...: Unable 
to transpile UDF ...: ['Python UDF transpiler: the transpiled UDF includes a 
NULL guard ...']`; the base branch's file constructs it with both options. 
Under a filter that escalates only this advisory, construction succeeds with 
zero options and only a fallback warning, so the UDF runs as interpreted 
Python. A blanket error filter would surface a correctly placed advisory too. 
What this placement adds is throwing away a successful lowering and reporting 
it as a transpilation 
 failure.
   
   See **Shared repair plan 1** in the review body.



##########
python/pyspark/sql/transpile.py:
##########
@@ -501,12 +564,15 @@ def _lower_value_compare(
             )
         left_col = self._convert_chunk(params, left_node)
         right_col = self._convert_chunk(params, right_node)
+        if self._is_nonnull_node(params, left_node) and 
self._is_nonnull_node(params, right_node):

Review Comment:
   **Nit (P3):** This fast path now skips the guard when both operands are 
proven non-null. The `_lower_value_compare` docstring above (lines 539-544) 
still says such callers 'take the otherwise branch' of a runtime guard, and the 
`len()` comment at 978-980 says the same next to the matching fast path at 
987-988. A reader would miss that the guard is now omitted statically, and that 
it stays unless every operand is proven non-null: `x > y` with only `x` 
narrowed keeps it. Both descriptions should state when the guard is omitted, 
for example by referring to `_is_nonnull_node`.
   
   **Verification:**
   - **Inspection:** Each description states that the guard is omitted when 
every operand is proven non-null, and neither still claims that proven callers 
take a runtime otherwise branch.



##########
python/pyspark/sql/tests/test_udf_transpile_unit.py:
##########
@@ -2750,6 +2750,148 @@ def 
test_udf_transpile_int_float_annotation_categories(self):
         self.assertEqual(len(combos_u), 3)
         self.assertEqual({c[0] for c in combos_u}, {"integer", "float", 
"string"})
 
+    def test_udf_transpile_null_narrowing_skips_null_guard(self):
+        # When a parameter is guarded by `x is not None` in an
+        # if/ternary test, the transpiler should skip the raise_error null 
guard
+        # for ordering comparisons on that parameter within the guarded branch.
+        def if_guard_gt(x):
+            return (x > 0) if x is not None else None
+
+        def if_guard_and_lt(a, b):
+            return (a < b) if a is not None and b is not None else None
+
+        def ternary_guard_ge(x):
+            return (x >= 0) if x is not None else None
+
+        def is_none_ternary(x):
+            return None if x is None else x > 0
+
+        cases_if = [
+            # if/else with `is not None` guard
+            ("if_guard_gt", if_guard_gt),
+            # if/else with `is not None` and-chain (two params)
+            ("if_guard_and_lt", if_guard_and_lt),
+            # ternary with `is not None` guard
+            ("ternary_guard_ge", ternary_guard_ge),
+            # `is None` guard: non-null in the else branch
+            ("is_none_ternary", is_none_ternary),
+        ]
+        with self.sql_conf(_TRANSPILE_ON):
+            for name, func in cases_if:
+                with self.subTest(func=name):
+                    u, warnings_text = self._udf_and_warnings(func, 
BooleanType())
+                    self.assertTrue(u.transpiled, f"{name} did not transpile: 
{warnings_text}")
+                    arity = func.__code__.co_argcount
+                    if arity == 1:
+                        df = self.spark.createDataFrame([(1,), (-1,), 
(None,)], "a long")
+                        plan = self._optimized_plan(df.select(u("a")))
+                        self.assertNotIn("raise_error", plan, f"{name}: plan 
had raise_error")

Review Comment:
   **Non-blocking (P2):** If the JVM discarded these options, the optimized 
plan would hold the PythonUDF, which has no `raise_error`, and interpreted 
Python would return the same values, so every assertion here would still pass. 
`_vals` documents exactly this trap, and the file's signal that the lowered 
option was kept, `_eval_python_count(...) == 0`, is not used here. Every case 
is also positive, so narrowing that leaked into the wrong branch would go 
unnoticed: `(x > 0) if x is None else None` would return NULL where Python 
raises TypeError. The captured warning text is never asserted on either, so a 
warning that fires for every narrowed UDF would pass too.
   
   See **Shared repair plan 2** in the review body.



##########
python/pyspark/sql/tests/test_udf_transpile_unit.py:
##########
@@ -2750,6 +2750,148 @@ def 
test_udf_transpile_int_float_annotation_categories(self):
         self.assertEqual(len(combos_u), 3)
         self.assertEqual({c[0] for c in combos_u}, {"integer", "float", 
"string"})
 
+    def test_udf_transpile_null_narrowing_skips_null_guard(self):

Review Comment:
   **Nit (P3):** Several boundaries the new logic introduces have no test. 
Nothing exercises the reversed `None is not p` / `None is p` forms or narrowing 
in an elif test. The early-return rewrite is safe only because it requires 
`orelse=[]`; without that, `if x is None: return 1` / `elif x > 0: return 2` / 
`return 3` would drop the elif and return 3 where Python returns 2, and no test 
pins that this body is still refused. The `len()` reclassification changes 
three outcomes, none of them tested: `return len(x) > 3` declared LongType now 
falls back, which matches the interpreted NULL; BooleanType `len(x) > 3` and 
`len(x) == 3` now lower; and `if len(x) > 3:` now lowers as a boolean test.
   
   See **Shared repair plan 2** in the review body.



##########
python/pyspark/sql/transpile.py:
##########
@@ -203,6 +203,9 @@ def _is_definitely_basic_type(node: ast.AST) -> bool:
             return _is_definitely_basic_type(operand)
         case ast.Name():
             return True
+        case ast.Call(func=ast.Name(id="len"), args=[_], keywords=[]):

Review Comment:
   **Blocking (P1):** With this arm, `len(s) > 10` is definitely boolean and 
`_category` labels it 'bool'. Both Mult arms accept only an 'integer' or 
'numeric' repeat count, so `def mark(s): return s + '*' * (len(s) > 10)` now 
raises at the Mult arm in every input-type variant and falls back to 
interpreted Python. On the base branch, the comparison fell into the 'numeric' 
catch-all, and the string variant lowered to `concat(s, repeat('*', 
CAST(<guarded comparison> AS INT)))`, an ANSI-legal cast that matches Python's 
`str * bool`. Running `udf()` on Spark 4.3.0 with each revision's transpile.py 
confirms this: with the base branch's file the plan has no BatchEvalPython and 
returns Python's values, and with this branch's file it plans BatchEvalPython. 
Results are unchanged, but every row now makes a pickled round trip through a 
Python worker. Arithmetic over the comparison, such as `(len(s) > 10) + 1`, is 
not affected, because its old option failed ANSI analysis.
   
   **Recommended change:** In both string-repeat arms, accept a count whose 
node is a comparison the transpiler classifies as boolean, and lower it as 
`repeat(str, CAST(count AS INT))`, as the base branch did for `len()` 
comparisons. Keep refusing other boolean counts, such as a ternary that can 
yield None. Add a UDF transpiler test showing `s + '*' * (len(s) > 10)` lowers 
and matches Python.
   
   **Why this works:** A lowered comparison is never NULL: ordering comparisons 
are guarded or narrowed, equality uses the four-branch None handling, and `is` 
checks become isNull or isNotNull. So `CAST(comparison AS INT)` is a 0 or 1 
count equal to Python's `str * bool`, and ANSI allows that cast.
   
   **Scope:** Accept comparison-valued string-repeat counts again.
   
   **Compatibility:** Numeric `*`, integer repeat counts, and the boolean 
classification of `len()` comparisons are unchanged.
   
   **Risks:** Repeat counts given by other comparisons, such as `'s' * (n != 
1)`, which the base branch refused, now lower natively too. Their results match 
Python, and removing the comparison case rolls this back.
   
   **Constraints:** Limit the new case to comparison nodes and keep refusing 
other boolean categories.
   
   **Success:** `s + '*' * (len(s) > 10)` declared StringType lowers to a 
native projection again and returns Python's values.
   
   **Verification:**
   - **Regression:** A UDF that uses a `len()` comparison as a string-repeat 
count produces a lowered option, the JVM keeps it, and the results match 
interpreted Python.
   - **Behavior:** A string-repeat count given by a ternary with a None branch 
still falls back.



##########
python/pyspark/sql/tests/test_udf_transpile_unit.py:
##########
@@ -2750,6 +2750,148 @@ def 
test_udf_transpile_int_float_annotation_categories(self):
         self.assertEqual(len(combos_u), 3)
         self.assertEqual({c[0] for c in combos_u}, {"integer", "float", 
"string"})
 
+    def test_udf_transpile_null_narrowing_skips_null_guard(self):
+        # When a parameter is guarded by `x is not None` in an
+        # if/ternary test, the transpiler should skip the raise_error null 
guard
+        # for ordering comparisons on that parameter within the guarded branch.
+        def if_guard_gt(x):
+            return (x > 0) if x is not None else None
+
+        def if_guard_and_lt(a, b):
+            return (a < b) if a is not None and b is not None else None
+
+        def ternary_guard_ge(x):
+            return (x >= 0) if x is not None else None
+
+        def is_none_ternary(x):
+            return None if x is None else x > 0
+
+        cases_if = [
+            # if/else with `is not None` guard

Review Comment:
   **Nit (P3):** `if_guard_gt` and `if_guard_and_lt` are conditional 
expressions (`(x > 0) if x is not None else None`), the same shape as the case 
labelled 'ternary', so despite its name and comments this list never reaches 
the `ast.If` narrowing arm. Separately, the assertion message in 
`test_udf_transpile_null_guard_warning` says 'under strict or loose', but 
transpile.py, udf.py, and the transpiler confs define no such modes.
   
   See **Shared repair plan 2** in the review body.



##########
python/pyspark/sql/transpile.py:
##########
@@ -256,6 +259,46 @@ def _truthiness_col(cat: Optional[str], c: Column) -> 
Optional[Column]:
     return None
 
 
+def _null_narrowed(params: List[str], test_node: ast.AST) -> Tuple[frozenset, 
frozenset]:
+    """Return ``(body_nonnull, else_nonnull)`` — frozensets of parameter names

Review Comment:
   **Nit (P3):** This docstring and the inline comment at line 293 add an em 
dash (U+2014), right arrows (U+2192, lines 268-272 and 293), and ellipses 
(U+2026). They are the only non-ASCII characters in transpile.py. Repository 
guidance asks for ASCII in code and comments, and the rest of the file uses 
`--`, `->`, and `...`, for example in `_truthiness_col`'s table.
   
   **Verification:**
   - **Inspection:** A scan for non-ASCII characters finds none in the 
transpiler module.



##########
python/pyspark/sql/transpile.py:
##########
@@ -991,6 +1083,15 @@ def _transpile_from_ast(
                     "interpreted Python"
                 )
         converted = self._convert_chunk(params, function_body[0])
+        if self._null_guard_emitted:
+            warnings.warn(
+                "Python UDF transpiler: the transpiled UDF includes a NULL 
guard "

Review Comment:
   **Nit (P3):** The message never says which UDF, parameter, or operator needs 
the guard. Because `stacklevel=2` resolves to `_transpile_func`'s call at line 
1566, Python attributes every copy to transpile.py rather than to user code. 
One copy is issued for each variant that kept a guard: two for an untyped `x > 
0`, five for an untyped two-parameter comparison. A UDF that only calls 
`len(s)` is told to guard comparisons it does not contain. The warning is also 
issued before the options are final, so `udf(f).asNondeterministic()` warns 
about a lowering that never runs. udf.py's own construction warnings all name 
the UDF (`{func}`).
   
   See **Shared repair plan 1** in the review body.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to