This is an automated email from the ASF dual-hosted git repository.

lvyanquan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink-cdc.git


The following commit(s) were added to refs/heads/master by this push:
     new 48944c6fcd [FLINK-40409][runtime] CDC YAML supports inline Python UDF 
definition (#4501)
48944c6fcd is described below

commit 48944c6fcd7f952464abee9e2a1c8829e5ac05d6
Author: yuxiqian <[email protected]>
AuthorDate: Wed Aug 19 11:19:52 2026 +0800

    [FLINK-40409][runtime] CDC YAML supports inline Python UDF definition 
(#4501)
---
 .github/workflows/flink_cdc_base.yml               |   9 +
 .github/workflows/modules.py                       |   1 +
 docs/content.zh/docs/core-concept/transform.md     | 110 ++++++++++
 docs/content/docs/core-concept/transform.md        | 112 ++++++++++
 .../cli/parser/YamlPipelineDefinitionParser.java   |  84 +++++++-
 .../parser/YamlPipelineDefinitionParserTest.java   | 198 +++++++++++++++++
 .../pipeline-definition-with-python-udf.yaml       |  37 ++++
 .../flink/cdc/common/udf/UserDefinedFunction.java  |  15 +-
 flink-cdc-dist/pom.xml                             |   5 +
 .../cdc/pipeline/tests/PythonUdfE2eITCase.java     | 221 +++++++++++++++++++
 .../src/test/resources/ddl/python_udf_test.sql     |  37 ++++
 .../src/test/resources/rules/unexpected.yaml       |   2 +-
 flink-cdc-python/pom.xml                           |  76 +++++++
 .../org/apache/flink/cdc/python/PythonUdf.java     | 236 +++++++++++++++++++++
 .../flink/cdc/python/utils/PythonUdfSignature.java |  85 ++++++++
 .../org/apache/flink/cdc/python/signature.py       |  54 +++++
 .../org/apache/flink/cdc/python/PythonUdfTest.java | 219 +++++++++++++++++++
 .../flink/cdc/python/utils/PemjaTestSupport.java   |  53 +++++
 .../cdc/python/utils/PythonUdfSignatureTest.java   |  90 ++++++++
 .../transform/TransformExpressionCompiler.java     |   2 +-
 .../transform/UserDefinedFunctionDescriptor.java   |  11 +-
 .../flink/cdc/runtime/parser/JaninoCompiler.java   |   6 +-
 .../UserDefinedFunctionDescriptorTest.java         |  24 +++
 .../cdc/runtime/parser/TransformParserTest.java    |  97 +++------
 pom.xml                                            |   1 +
 25 files changed, 1699 insertions(+), 86 deletions(-)

diff --git a/.github/workflows/flink_cdc_base.yml 
b/.github/workflows/flink_cdc_base.yml
index 982ea81e3e..c71195e171 100644
--- a/.github/workflows/flink_cdc_base.yml
+++ b/.github/workflows/flink_cdc_base.yml
@@ -89,6 +89,15 @@ jobs:
         with:
           maven-version: 3.8.6
 
+      - name: Install Python and Pemja
+        # Required by flink-cdc-python unit tests.
+        if: ${{ matrix.module == 'core' }}
+        run: |
+          set -euo pipefail
+          sudo apt-get update
+          sudo apt-get install -y python3 python3-pip python3-dev
+          python3 -m pip install --disable-pip-version-check pemja==0.5.7
+
       - name: Compile and test
         timeout-minutes: 90
         run: |
diff --git a/.github/workflows/modules.py b/.github/workflows/modules.py
index d92a4fc653..123b20b620 100755
--- a/.github/workflows/modules.py
+++ b/.github/workflows/modules.py
@@ -14,6 +14,7 @@ MODULES_CORE = [
     "flink-cdc-composer",
     "flink-cdc-runtime",
     "flink-cdc-pipeline-model/flink-cdc-pipeline-model-openai-compatible",
+    "flink-cdc-python",
     "flink-cdc-connect/flink-cdc-source-connectors/flink-cdc-base",
     
"flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-values"
 ]
diff --git a/docs/content.zh/docs/core-concept/transform.md 
b/docs/content.zh/docs/core-concept/transform.md
index 0409d7a6a1..aad269c88a 100644
--- a/docs/content.zh/docs/core-concept/transform.md
+++ b/docs/content.zh/docs/core-concept/transform.md
@@ -528,3 +528,113 @@ transform:
 ```
 
 有关 AI 模型函数和配置,请参见 [AI 模型]({{< ref "docs/core-concept/ai-model" >}})。
+
+### Python UDF
+
+Flink CDC 支持直接在 Pipeline YAML 中定义 Python UDF。发行包内置了 `flink-cdc-python`
+模块,通过 [Pemja](https://pypi.org/project/pemja/) 在每个 TaskManager 中嵌入 Python。
+
+```yaml
+transform:
+  - source-table: db.users
+    projection: ID, py_normalize(EMAIL) AS EMAIL_NORM, py_double(AGE) AS 
DOUBLED
+
+pipeline:
+  user-defined-function:
+    - name: py_normalize
+      python-code: |
+        def eval(s: str) -> str:
+            return None if s is None else s.strip().lower()
+      python-executable: /usr/bin/python3
+    - name: py_double
+      python-code: |
+        def eval(x: int) -> int:
+            return None if x is None else x * 2
+      python-files:
+        - /opt/flink/python-deps
+        - /opt/flink/python-deps.zip
+```
+
+每个条目必须包含一个名为 `eval` 的顶层函数,并声明返回值类型。目前支持 `bool`、
+`bytes`、`float`、`int` 和 `str`,依次映射为 Flink CDC 的 `BOOLEAN`、`BYTES`、
+`DOUBLE`、`BIGINT` 和 `STRING`。
+
+运行时还需满足以下条件:
+
+* 每个 TaskManager 都要安装 Python 和 `pemja==0.5.7`。`python-executable` 默认使用
+  `PATH` 中找到的第一个 `python3`。
+* 可选的 `python-files` 是一个 YAML 列表,元素只能是已存在的目录或 `.zip` 压缩包;
+  这些路径必须在每个 TaskManager 上都可访问。压缩包解压后才会加入 Python import path。
+* `python-code` 不能与 `classpath` 或 `options` 同时使用。每个 Python 函数应单独声明为
+  一个 UDF 条目。
+
+## Embedding AI 模型
+
+内置 AI 模型可以在 transform 规则中使用。
+为了使用内置 AI 模型,你需要下载内置模型的 jar ,然后在 `flink-cdc.sh` 命令中添加 `--jar 
{$BUILT_IN_MODEL_PATH}`。
+
+如何定义一个 Embedding AI 模型:
+
+```yaml
+pipeline:
+  model:
+    - model-name: CHAT
+      class-name: OpenAIChatModel
+      openai.model: gpt-4o-mini
+      openai.host: https://xxxx
+      openai.apikey: abcd1234
+      openai.chat.prompt: please summary this
+    - model-name: GET_EMBEDDING
+      class-name: OpenAIEmbeddingModel
+      openai.model: text-embedding-3-small
+      openai.host: https://xxxx
+      openai.apikey: abcd1234
+```
+注意:
+* `model-name` 是一个通用的必填参数,用于所有支持的模型,表示在 `projection` 或 `filter` 中调用的函数名称。
+* `class-name` 是一个通用的必填参数,用于所有支持的模型,可用值可以在[所有支持的模型](#all-support-models)中找到。
+* `openai.model`,`openai.host`,`openai.apiKey` 和 `openai.chat.prompt` 
是在各个模型中特别的可选参数。
+
+如何使用一个 Embedding AI 模型:
+
+```yaml
+transform:
+  - source-table: db.\.*
+    projection: "*, inc(inc(inc(id))) as inc_id, GET_EMBEDDING(page) as emb, 
CHAT(page) as summary"
+    filter: inc(id) < 100
+pipeline:
+  model:
+    - model-name: CHAT
+      class-name: OpenAIChatModel
+      openai.model: gpt-4o-mini
+      openai.host: http://langchain4j.dev/demo/openai/v1
+      openai.apikey: demo
+      openai.chat.prompt: please summary this
+    - model-name: GET_EMBEDDING
+      class-name: OpenAIEmbeddingModel
+      openai.model: text-embedding-3-small
+      openai.host: http://langchain4j.dev/demo/openai/v1
+      openai.apikey: demo
+```
+这里,GET_EMBEDDING 是通过 `model-name` 在 `pipeline` 中定义的。
+
+### 所有支持的模型
+
+下面列出了所有支持的模型:
+
+#### OpenAIChatModel
+
+| 参数                 | 类型     | 是否必填     | 含义                                  
                                                               |
+|--------------------|--------|----------|-----------------------------------------------------------------------------------------------------|
+| openai.model       | STRING | 必填       | 要调用的模型名称,例如:"gpt-4o-mini",可用选项有 
"gpt-4o-mini"、"gpt-4o"、"gpt-4-32k"、"gpt-3.5-turbo"。 |
+| openai.host        | STRING | 必填       | 
要连接的模型服务器地址,例如:`http://langchain4j.dev/demo/openai/v1`。                         
        |
+| openai.apikey      | STRING | 必填       | 模型服务器验证的 API Key,例如:"demo"。         
                                                       |
+| openai.chat.prompt | STRING | 可选       | 与 OpenAI 聊天的提示词,例如:"Please summary 
this"。                                                   |
+
+#### OpenAIEmbeddingModel
+
+| 参数            | 类型     | 是否必填     | 含义                                       
                                                                            |
+|---------------|--------|----------|----------------------------------------------------------------------------------------------------------------------|
+| openai.model  | STRING | 必填       | 
要调用的模型名称,例如:"text-embedding-3-small",可用选项有 
"text-embedding-3-small"、"text-embedding-3-large"、"text-embedding-ada-002"。 |
+| openai.host   | STRING | 必填       | 
要连接的模型服务器地址,例如:`http://langchain4j.dev/demo/openai/v1`。                         
                          |
+| openai.apikey | STRING | 必填       | 模型服务器验证的 API Key,例如:"demo"。              
                                                                    |
diff --git a/docs/content/docs/core-concept/transform.md 
b/docs/content/docs/core-concept/transform.md
index 8e2f9815c7..5ff24b9b54 100644
--- a/docs/content/docs/core-concept/transform.md
+++ b/docs/content/docs/core-concept/transform.md
@@ -533,3 +533,115 @@ transform:
 ```
 
 For AI model functions and configuration, see [AI Model]({{< ref 
"docs/core-concept/ai-model" >}}).
+
+### Python UDFs
+
+Flink CDC supports defining Python UDFs inline in the pipeline YAML. The 
distribution includes the
+`flink-cdc-python` module, which embeds Python on each TaskManager through
+[Pemja](https://pypi.org/project/pemja/).
+
+```yaml
+transform:
+  - source-table: db.users
+    projection: ID, py_normalize(EMAIL) AS EMAIL_NORM, py_double(AGE) AS 
DOUBLED
+
+pipeline:
+  user-defined-function:
+    - name: py_normalize
+      python-code: |
+        def eval(s: str) -> str:
+            return None if s is None else s.strip().lower()
+      python-executable: /usr/bin/python3
+    - name: py_double
+      python-code: |
+        def eval(x: int) -> int:
+            return None if x is None else x * 2
+      python-files:
+        - /opt/flink/python-deps
+        - /opt/flink/python-deps.zip
+```
+
+Each entry must contain one top-level function named `eval` with a return type 
annotation. Supported
+annotations are `bool`, `bytes`, `float`, `int`, and `str`, mapped to Flink 
CDC `BOOLEAN`, `BYTES`,
+`DOUBLE`, `BIGINT`, and `STRING`, respectively.
+
+The following runtime requirements apply:
+
+* Every TaskManager must have Python and `pemja==0.5.7` installed. 
`python-executable` defaults to
+  the first `python3` on `PATH`.
+* `python-files` is optional and accepts a YAML list of existing directories 
or `.zip` archives.
+  These paths must be available on every TaskManager. Zip archives are 
extracted before being added
+  to Python's import path.
+* `python-code` cannot be combined with `classpath` or `options`. Use one UDF 
entry for each Python
+  function.
+
+## Embedding AI Model
+
+Embedding AI Model can be used in transform rules.
+To use Embedding AI Model, you need to download the jar of build-in model, and 
then add `--jar {$BUILT_IN_MODEL_PATH}` to your flink-cdc.sh command.
+
+How to define a Embedding AI Model:
+
+```yaml
+pipeline:
+  model:
+    - model-name: CHAT
+      class-name: OpenAIChatModel
+      openai.model: text-embedding-3-small
+      openai.host: https://xxxx
+      openai.apikey: abcd1234
+      openai.chat.prompt: please summary this
+    - model-name: GET_EMBEDDING
+      class-name: OpenAIEmbeddingModel
+      openai.model: text-embedding-3-small
+      openai.host: https://xxxx
+      openai.apikey: abcd1234
+```
+Note:
+* `model-name` is a common required parameter for all support models, which 
represent the function name called in `projection` or `filter`.
+* `class-name` is a common required parameter for all support models, 
available values can be found in [All Support models](#all-support-models).
+* `openai.model`, `openai.host`, `openai.apiKey` and `openai.chat.prompt` is 
option parameters that defined in specific model.
+
+How to use a Embedding AI Model:
+
+```yaml
+transform:
+  - source-table: db.\.*
+    projection: "*, inc(inc(inc(id))) as inc_id, GET_EMBEDDING(page) as emb, 
CHAT(page) as summary"
+    filter: inc(id) < 100
+pipeline:
+  model:
+    - model-name: CHAT
+      class-name: OpenAIChatModel
+      openai.model: gpt-4o-mini
+      openai.host: http://langchain4j.dev/demo/openai/v1
+      openai.apikey: demo
+      openai.chat.prompt: please summary this
+    - model-name: GET_EMBEDDING
+      class-name: OpenAIEmbeddingModel
+      openai.model: text-embedding-3-small
+      openai.host: http://langchain4j.dev/demo/openai/v1
+      openai.apikey: demo
+```
+Here, GET_EMBEDDING is defined though `model-name` in `pipeline`.
+
+### All Support models
+
+The following built-in models are provided:
+
+#### OpenAIChatModel
+
+| parameter          | type   | optional/required | meaning                    
                                                                                
                          |
+|--------------------|--------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------|
+| openai.model       | STRING | required          | Name of model to be 
called, for example: "gpt-4o-mini", Available options are "gpt-4o-mini", 
"gpt-4o", "gpt-4-32k", "gpt-3.5-turbo". |
+| openai.host        | STRING | required          | Host of the Model server 
to be connected, for example: `http://langchain4j.dev/demo/openai/v1`.          
                            |
+| openai.apikey      | STRING | required          | Api Key for verification 
of the Model server, for example, "demo".                                       
                            |
+| openai.chat.prompt | STRING | optional          | Prompt for chatting with 
OpenAI, for example: "Please summary this ".                                    
                            |
+
+#### OpenAIEmbeddingModel
+
+| parameter     | type   | optional/required | meaning                         
                                                                                
                                                       |
+|---------------|--------|-------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| openai.model  | STRING | required          | Name of model to be called, for 
example: "text-embedding-3-small", Available options are 
"text-embedding-3-small", "text-embedding-3-large", "text-embedding-ada-002". |
+| openai.host   | STRING | required          | Host of the Model server to be 
connected, for example: `http://langchain4j.dev/demo/openai/v1`.                
                                                        |
+| openai.apikey | STRING | required          | Api Key for verification of the 
Model server, for example, "demo".                                              
                                                       |
diff --git 
a/flink-cdc-cli/src/main/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParser.java
 
b/flink-cdc-cli/src/main/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParser.java
index b5519afd5a..98573104df 100644
--- 
a/flink-cdc-cli/src/main/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParser.java
+++ 
b/flink-cdc-cli/src/main/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParser.java
@@ -44,6 +44,7 @@ import 
org.apache.flink.shaded.jackson2.com.fasterxml.jackson.dataformat.yaml.YA
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
@@ -94,7 +95,11 @@ public class YamlPipelineDefinitionParser implements 
PipelineDefinitionParser {
     private static final String UDF_KEY = "user-defined-function";
     private static final String UDF_FUNCTION_NAME_KEY = "name";
     private static final String UDF_CLASSPATH_KEY = "classpath";
+    private static final String UDF_PYTHON_CODE_KEY = "python-code";
+    private static final String UDF_PYTHON_EXECUTABLE_KEY = 
"python-executable";
+    private static final String UDF_PYTHON_FILES_KEY = "python-files";
     private static final String UDF_OPTIONS_KEY = "options";
+    private static final String PYTHON_UDF_CLASSPATH = 
"org.apache.flink.cdc.python.PythonUdf";
 
     // Model related keys
     private static final String MODEL_NAME_KEY = "name";
@@ -313,8 +318,13 @@ public class YamlPipelineDefinitionParser implements 
PipelineDefinitionParser {
         validateJsonNodeKeys(
                 "UDF",
                 udfNode,
-                Arrays.asList(UDF_FUNCTION_NAME_KEY, UDF_CLASSPATH_KEY),
-                Collections.singletonList(UDF_OPTIONS_KEY));
+                Collections.singletonList(UDF_FUNCTION_NAME_KEY),
+                Arrays.asList(
+                        UDF_CLASSPATH_KEY,
+                        UDF_PYTHON_CODE_KEY,
+                        UDF_PYTHON_EXECUTABLE_KEY,
+                        UDF_PYTHON_FILES_KEY,
+                        UDF_OPTIONS_KEY));
 
         String functionName =
                 checkNotNull(
@@ -322,12 +332,22 @@ public class YamlPipelineDefinitionParser implements 
PipelineDefinitionParser {
                                 "Missing required field \"%s\" in UDF 
configuration",
                                 UDF_FUNCTION_NAME_KEY)
                         .asText();
-        String classpath =
-                checkNotNull(
-                                udfNode.get(UDF_CLASSPATH_KEY),
-                                "Missing required field \"%s\" in UDF 
configuration",
-                                UDF_CLASSPATH_KEY)
-                        .asText();
+        JsonNode classpathNode = udfNode.get(UDF_CLASSPATH_KEY);
+        JsonNode pythonCodeNode = udfNode.get(UDF_PYTHON_CODE_KEY);
+
+        Preconditions.checkArgument(
+                classpathNode != null || pythonCodeNode != null,
+                "Missing required field \"%s\" or \"%s\" in UDF configuration",
+                UDF_CLASSPATH_KEY,
+                UDF_PYTHON_CODE_KEY);
+        Preconditions.checkArgument(
+                classpathNode == null || pythonCodeNode == null,
+                "UDF configuration cannot define both \"%s\" and \"%s\"",
+                UDF_CLASSPATH_KEY,
+                UDF_PYTHON_CODE_KEY);
+
+        JsonNode pythonExecutableNode = udfNode.get(UDF_PYTHON_EXECUTABLE_KEY);
+        JsonNode pythonFilesNode = udfNode.get(UDF_PYTHON_FILES_KEY);
 
         Map<String, String> options =
                 Optional.ofNullable(udfNode.get(UDF_OPTIONS_KEY))
@@ -335,11 +355,57 @@ public class YamlPipelineDefinitionParser implements 
PipelineDefinitionParser {
                                 node ->
                                         mapper.convertValue(
                                                 node, new 
TypeReference<Map<String, String>>() {}))
-                        .orElse(null);
+                        .orElseGet(HashMap::new);
+
+        if (pythonCodeNode != null) {
+            Preconditions.checkArgument(
+                    udfNode.get(UDF_OPTIONS_KEY) == null,
+                    "UDF configuration using \"%s\" cannot define \"%s\"; use 
top-level \"%s\" and \"%s\" instead",
+                    UDF_PYTHON_CODE_KEY,
+                    UDF_OPTIONS_KEY,
+                    UDF_PYTHON_EXECUTABLE_KEY,
+                    UDF_PYTHON_FILES_KEY);
+            options.put("source", pythonCodeNode.asText());
+            Optional.ofNullable(pythonExecutableNode)
+                    .map(JsonNode::asText)
+                    .ifPresent(value -> options.put("python-executable", 
value));
+            Optional.ofNullable(normalizePythonFiles(pythonFilesNode))
+                    .ifPresent(value -> options.put("python-files", value));
+            return new UdfDef(functionName, PYTHON_UDF_CLASSPATH, options);
+        }
 
+        Preconditions.checkArgument(
+                pythonExecutableNode == null && pythonFilesNode == null,
+                "UDF configuration using \"%s\" or \"%s\" requires \"%s\"",
+                UDF_PYTHON_EXECUTABLE_KEY,
+                UDF_PYTHON_FILES_KEY,
+                UDF_PYTHON_CODE_KEY);
+
+        String classpath = classpathNode.asText();
         return new UdfDef(functionName, classpath, options);
     }
 
+    private String normalizePythonFiles(JsonNode pythonFilesNode) {
+        if (pythonFilesNode == null || pythonFilesNode.isNull()) {
+            return null;
+        }
+        if (pythonFilesNode.isTextual()) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "YAML UDF field `%s` should be a list when used 
with `python-code`.",
+                            UDF_PYTHON_FILES_KEY));
+        }
+        if (!pythonFilesNode.isArray()) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "YAML UDF field `%s` should be a list, but got 
%s.",
+                            UDF_PYTHON_FILES_KEY, 
pythonFilesNode.getNodeType()));
+        }
+        List<String> pythonFiles = new ArrayList<>();
+        pythonFilesNode.forEach(node -> pythonFiles.add(node.asText()));
+        return String.join(",", pythonFiles);
+    }
+
     private TransformDef toTransformDef(JsonNode transformNode) {
         validateJsonNodeKeys(
                 "transform",
diff --git 
a/flink-cdc-cli/src/test/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParserTest.java
 
b/flink-cdc-cli/src/test/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParserTest.java
index 834831b6cb..a1673723c1 100644
--- 
a/flink-cdc-cli/src/test/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParserTest.java
+++ 
b/flink-cdc-cli/src/test/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParserTest.java
@@ -207,6 +207,139 @@ class YamlPipelineDefinitionParserTest {
         assertThat(pipelineDef).isEqualTo(pipelineDefWithUdfOptions);
     }
 
+    @Test
+    void testPythonUdfDefinition() throws Exception {
+        URL resource =
+                
Resources.getResource("definitions/pipeline-definition-with-python-udf.yaml");
+        YamlPipelineDefinitionParser parser = new 
YamlPipelineDefinitionParser();
+        PipelineDef pipelineDef = parser.parse(new Path(resource.toURI()), new 
Configuration());
+        assertThat(pipelineDef).isEqualTo(pipelineDefWithPythonUdf);
+    }
+
+    @Test
+    void testPythonUdfRejectsClasspathAndPythonCodeTogether() {
+        YamlPipelineDefinitionParser parser = new 
YamlPipelineDefinitionParser();
+        assertThatThrownBy(
+                        () ->
+                                parser.parse(
+                                        buildPipelineDefWithPythonUdf(
+                                                "py_identity",
+                                                "classpath: 
org.example.MyFunction\n"
+                                                        + "      python-code: 
|\n"
+                                                        + "        def eval(x: 
int) -> int:\n"
+                                                        + "          return 
x\n"
+                                                        + "      
python-executable: /usr/bin/python3\n"),
+                                        new Configuration()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage(
+                        "UDF configuration cannot define both \"classpath\" 
and \"python-code\"");
+    }
+
+    @Test
+    void testPythonUdfRejectsOptionsAlongsidePythonCode() {
+        YamlPipelineDefinitionParser parser = new 
YamlPipelineDefinitionParser();
+        assertThatThrownBy(
+                        () ->
+                                parser.parse(
+                                        buildPipelineDefWithPythonUdf(
+                                                "py_identity",
+                                                "python-code: |\n"
+                                                        + "        def eval(x: 
int) -> int:\n"
+                                                        + "          return 
x\n"
+                                                        + "      options:\n"
+                                                        + "        
cache.enabled: true\n"),
+                                        new Configuration()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage(
+                        "UDF configuration using \"python-code\" cannot define 
\"options\"; use top-level \"python-executable\" and \"python-files\" instead");
+    }
+
+    @Test
+    void testPythonExecutableRequiresPythonCode() {
+        YamlPipelineDefinitionParser parser = new 
YamlPipelineDefinitionParser();
+        assertThatThrownBy(
+                        () ->
+                                parser.parse(
+                                        buildPipelineDefWithPythonUdf(
+                                                "py_identity",
+                                                "python-executable: 
/usr/bin/python3\n"
+                                                        + "      classpath: 
org.example.MyFunction\n"),
+                                        new Configuration()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage(
+                        "UDF configuration using \"python-executable\" or 
\"python-files\" requires \"python-code\"");
+    }
+
+    @Test
+    void testPythonFilesRequiresPythonCode() {
+        YamlPipelineDefinitionParser parser = new 
YamlPipelineDefinitionParser();
+        assertThatThrownBy(
+                        () ->
+                                parser.parse(
+                                        buildPipelineDefWithPythonUdf(
+                                                "py_identity",
+                                                "python-files:\n"
+                                                        + "        - 
/flink/usrlib/deps.zip\n"
+                                                        + "      classpath: 
org.example.MyFunction\n"),
+                                        new Configuration()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage(
+                        "UDF configuration using \"python-executable\" or 
\"python-files\" requires \"python-code\"");
+    }
+
+    @Test
+    void testPythonFilesMustUseListSyntax() {
+        YamlPipelineDefinitionParser parser = new 
YamlPipelineDefinitionParser();
+        assertThatThrownBy(
+                        () ->
+                                parser.parse(
+                                        buildPipelineDefWithPythonUdf(
+                                                "py_identity",
+                                                "python-code: |\n"
+                                                        + "        def eval(x: 
int) -> int:\n"
+                                                        + "          return 
x\n"
+                                                        + "      python-files: 
/flink/usrlib/deps.zip\n"),
+                                        new Configuration()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage(
+                        "YAML UDF field `python-files` should be a list when 
used with `python-code`.");
+    }
+
+    @Test
+    void testUdfRequiresClasspathOrPythonCode() {
+        YamlPipelineDefinitionParser parser = new 
YamlPipelineDefinitionParser();
+        assertThatThrownBy(
+                        () ->
+                                parser.parse(
+                                        
buildPipelineDefWithPythonUdf("py_identity", ""),
+                                        new Configuration()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage(
+                        "Missing required field \"classpath\" or 
\"python-code\" in UDF configuration");
+    }
+
+    @Test
+    void testPythonUdfRequiresName() {
+        YamlPipelineDefinitionParser parser = new 
YamlPipelineDefinitionParser();
+        assertThatThrownBy(
+                        () ->
+                                parser.parse(
+                                        "source:\n"
+                                                + "  type: values\n"
+                                                + "\n"
+                                                + "sink:\n"
+                                                + "  type: values\n"
+                                                + "\n"
+                                                + "pipeline:\n"
+                                                + "  user-defined-function:\n"
+                                                + "    - python-code: |\n"
+                                                + "        def eval(x: int) -> 
int:\n"
+                                                + "          return x\n",
+                                        new Configuration()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("Missing required field \"name\" in UDF 
configuration");
+    }
+
     @Test
     void testRouteMode() throws Exception {
         URL resource =
@@ -899,6 +1032,49 @@ class YamlPipelineDefinitionParserTest {
                                     .put("parallelism", "1")
                                     .build()));
 
+    private final PipelineDef pipelineDefWithPythonUdf =
+            new PipelineDef(
+                    new SourceDef("values", null, new Configuration()),
+                    new SinkDef(
+                            "values",
+                            null,
+                            new Configuration(),
+                            ImmutableSet.of(
+                                    ALTER_TABLE_COMMENT,
+                                    DROP_COLUMN,
+                                    ALTER_COLUMN_TYPE,
+                                    ADD_COLUMN,
+                                    CREATE_TABLE,
+                                    RENAME_COLUMN)),
+                    Collections.emptyList(),
+                    Collections.singletonList(
+                            new TransformDef(
+                                    "mydb.web_order",
+                                    "*, py_identity(id) as py_id",
+                                    null,
+                                    null,
+                                    null,
+                                    null,
+                                    ",",
+                                    null,
+                                    null)),
+                    Collections.singletonList(
+                            new UdfDef(
+                                    "py_identity",
+                                    "org.apache.flink.cdc.python.PythonUdf",
+                                    ImmutableMap.<String, String>builder()
+                                            .put("source", "def eval(x: int) 
-> int:\n  return x\n")
+                                            .put("python-executable", 
"/usr/bin/python3")
+                                            .put(
+                                                    "python-files",
+                                                    
"/flink/usrlib/deps.zip,/flink/usrlib/shared")
+                                            .build())),
+                    Collections.emptyList(),
+                    Configuration.fromMap(
+                            ImmutableMap.<String, String>builder()
+                                    .put("parallelism", "1")
+                                    .build()));
+
     private final PipelineDef pipelineDefWithRouteMode =
             new PipelineDef(
                     new SourceDef(
@@ -955,4 +1131,26 @@ class YamlPipelineDefinitionParserTest {
                                     .put("parallelism", "2")
                                     .put("route-mode", "FIRST_MATCH")
                                     .build()));
+
+    private static String buildPipelineDefWithPythonUdf(String name, String 
udfBody) {
+        return "source:\n"
+                + "  type: values\n"
+                + "\n"
+                + "sink:\n"
+                + "  type: values\n"
+                + "\n"
+                + "transform:\n"
+                + "  - source-table: mydb.web_order\n"
+                + "    projection: \"*, "
+                + name
+                + "(id) as py_id\"\n"
+                + "\n"
+                + "pipeline:\n"
+                + "  parallelism: 1\n"
+                + "  user-defined-function:\n"
+                + "    - name: "
+                + name
+                + "\n"
+                + (udfBody.isEmpty() ? "" : "      " + udfBody);
+    }
 }
diff --git 
a/flink-cdc-cli/src/test/resources/definitions/pipeline-definition-with-python-udf.yaml
 
b/flink-cdc-cli/src/test/resources/definitions/pipeline-definition-with-python-udf.yaml
new file mode 100644
index 0000000000..361f60c211
--- /dev/null
+++ 
b/flink-cdc-cli/src/test/resources/definitions/pipeline-definition-with-python-udf.yaml
@@ -0,0 +1,37 @@
+################################################################################
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+source:
+  type: values
+
+sink:
+  type: values
+
+transform:
+  - source-table: mydb.web_order
+    projection: "*, py_identity(id) as py_id"
+
+pipeline:
+  parallelism: 1
+  user-defined-function:
+    - name: py_identity
+      python-code: |
+        def eval(x: int) -> int:
+          return x
+      python-executable: /usr/bin/python3
+      python-files:
+        - /flink/usrlib/deps.zip
+        - /flink/usrlib/shared
diff --git 
a/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/udf/UserDefinedFunction.java
 
b/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/udf/UserDefinedFunction.java
index 0133e43945..0fa5d78c8c 100644
--- 
a/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/udf/UserDefinedFunction.java
+++ 
b/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/udf/UserDefinedFunction.java
@@ -26,15 +26,26 @@ import org.apache.flink.cdc.common.types.DataType;
  */
 @PublicEvolving
 public interface UserDefinedFunction {
+
+    /**
+     * Returns the return type of this UDF when it cannot be derived from 
per-UDF options.
+     *
+     * @deprecated Use {@link #getReturnType(UserDefinedFunctionContext)} 
instead.
+     */
+    @Deprecated
     default DataType getReturnType() {
         return null;
     }
 
+    /** Returns the return type of this UDF, given the per-UDF YAML options. */
+    default DataType getReturnType(UserDefinedFunctionContext context) {
+        return getReturnType();
+    }
+
     /**
      * This will be invoked every time when a UDF got created.
      *
-     * <p>this method is {@link Deprecated}, please use {@link 
#open(UserDefinedFunctionContext)}
-     * instead.
+     * @deprecated Use {@link #open(UserDefinedFunctionContext)} instead.
      */
     @Deprecated
     default void open() throws Exception {}
diff --git a/flink-cdc-dist/pom.xml b/flink-cdc-dist/pom.xml
index 8c918fddc9..951b52f70d 100644
--- a/flink-cdc-dist/pom.xml
+++ b/flink-cdc-dist/pom.xml
@@ -52,6 +52,11 @@ limitations under the License.
             <artifactId>flink-cdc-composer</artifactId>
             <version>${project.version}</version>
         </dependency>
+        <dependency>
+            <groupId>org.apache.flink</groupId>
+            <artifactId>flink-cdc-python</artifactId>
+            <version>${project.version}</version>
+        </dependency>
     </dependencies>
 
     <profiles>
diff --git 
a/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/PythonUdfE2eITCase.java
 
b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/PythonUdfE2eITCase.java
new file mode 100644
index 0000000000..66c40f8205
--- /dev/null
+++ 
b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/PythonUdfE2eITCase.java
@@ -0,0 +1,221 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.pipeline.tests;
+
+import org.apache.flink.cdc.connectors.mysql.testutils.UniqueDatabase;
+import org.apache.flink.cdc.pipeline.tests.utils.PipelineTestEnvironment;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.Container;
+import org.testcontainers.containers.ExecConfig;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.images.builder.Transferable;
+
+import javax.annotation.Nullable;
+
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/** E2e tests for pipelines that use Python UDFs. */
+class PythonUdfE2eITCase extends PipelineTestEnvironment {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(PythonUdfE2eITCase.class);
+
+    private static final String CONTAINER_PYTHON_EXECUTABLE = 
"/usr/bin/python3";
+    private static final String PYTHON_FILES_DIRECTORY = 
"/opt/flink/python-deps";
+    private static final String PEMJA_VERSION = "0.5.7";
+
+    private final UniqueDatabase pythonUdfTestDatabase =
+            new UniqueDatabase(MYSQL, "python_udf_test", MYSQL_TEST_USER, 
MYSQL_TEST_PASSWORD);
+
+    private final Function<String, String> databaseNameFormatter =
+            event -> String.format(event, 
pythonUdfTestDatabase.getDatabaseName());
+
+    @BeforeEach
+    void initializeDatabaseAndPython() throws Exception {
+        pythonUdfTestDatabase.createAndInitialize();
+        installPythonAndPemja(taskManager);
+        preparePythonFiles(taskManager);
+    }
+
+    @AfterEach
+    void destroyDatabase() {
+        pythonUdfTestDatabase.dropDatabase();
+    }
+
+    @Test
+    void testMultiplePythonUdfsWithFilterAndPythonFiles() throws Exception {
+        String pipelineJob =
+                buildJobYaml(
+                        "ID, py_normalize(EMAIL) AS EMAIL_NORM, py_double(AGE) 
AS DOUBLED",
+                        "py_age_ge_30(AGE)",
+                        Arrays.asList(
+                                new UdfEntry(
+                                        "py_normalize",
+                                        "from python_udf_helpers import 
normalize_email\n"
+                                                + "def eval(value: str) -> 
str:\n"
+                                                + "    return 
normalize_email(value)",
+                                        PYTHON_FILES_DIRECTORY),
+                                new UdfEntry(
+                                        "py_double",
+                                        "def eval(value: int) -> int:\n"
+                                                + "    return None if value is 
None else value * 2",
+                                        null),
+                                new UdfEntry(
+                                        "py_age_ge_30",
+                                        "def eval(value: int) -> bool:\n"
+                                                + "    return value is not 
None and value >= 30",
+                                        null)));
+
+        submitPipelineJob(pipelineJob);
+        waitUntilJobRunning(Duration.ofSeconds(60));
+        validateResult(
+                databaseNameFormatter,
+                "CreateTableEvent{tableId=%s.USERS, schema=columns={`ID` INT 
NOT NULL,`EMAIL_NORM` STRING,`DOUBLED` BIGINT}, primaryKeys=ID, options=()}",
+                "DataChangeEvent{tableId=%s.USERS, before=[], after=[3, 
[email protected], 70], op=INSERT, meta=()}",
+                "DataChangeEvent{tableId=%s.USERS, before=[], after=[4, 
[email protected], 84], op=INSERT, meta=()}");
+    }
+
+    private String buildJobYaml(
+            String projection, @Nullable String filter, List<UdfEntry> 
userDefinedFunctions) {
+        StringBuilder transform =
+                new StringBuilder("transform:\n")
+                        .append("  - source-table: ")
+                        .append(pythonUdfTestDatabase.getDatabaseName())
+                        .append(".USERS\n")
+                        .append("    projection: ")
+                        .append(projection)
+                        .append('\n');
+        if (filter != null) {
+            transform.append("    filter: ").append(filter).append('\n');
+        }
+
+        StringBuilder udfYaml = new StringBuilder();
+        for (UdfEntry udf : userDefinedFunctions) {
+            String indentedSource =
+                    Arrays.stream(udf.source.split("\n", -1))
+                            .map(line -> line.isEmpty() ? "" : "        " + 
line)
+                            .collect(Collectors.joining("\n"));
+            udfYaml.append("    - name: ").append(udf.name).append('\n');
+            udfYaml.append("      python-code: |\n");
+            udfYaml.append(indentedSource).append('\n');
+            udfYaml.append("      python-executable: ")
+                    .append(CONTAINER_PYTHON_EXECUTABLE)
+                    .append('\n');
+            if (udf.pythonFiles != null) {
+                udfYaml.append("      python-files:\n");
+                udfYaml.append("        - 
").append(udf.pythonFiles).append('\n');
+            }
+        }
+
+        return String.format(
+                "source:\n"
+                        + "  type: mysql\n"
+                        + "  hostname: %s\n"
+                        + "  port: 3306\n"
+                        + "  username: %s\n"
+                        + "  password: %s\n"
+                        + "  scan.startup.mode: earliest-offset\n"
+                        + "  tables: %s.USERS\n"
+                        + "  server-id: 5400-5404\n"
+                        + "  server-time-zone: UTC\n"
+                        + "\n"
+                        + "sink:\n"
+                        + "  type: values\n"
+                        + "\n"
+                        + "%s"
+                        + "\n"
+                        + "pipeline:\n"
+                        + "  parallelism: %d\n"
+                        + "  user-defined-function:\n"
+                        + "%s",
+                INTER_CONTAINER_MYSQL_ALIAS,
+                MYSQL_TEST_USER,
+                MYSQL_TEST_PASSWORD,
+                pythonUdfTestDatabase.getDatabaseName(),
+                transform,
+                parallelism,
+                udfYaml);
+    }
+
+    private void installPythonAndPemja(GenericContainer<?> container) throws 
Exception {
+        LOG.info(
+                "Installing Python and Pemja {} into {}",
+                PEMJA_VERSION,
+                container.getDockerImageName());
+        String externallyManagedOption =
+                flinkVersion.startsWith("2") ? "--break-system-packages " : "";
+        String script =
+                "set -euo pipefail; "
+                        + "apt-get update && "
+                        + "apt-get install -y --no-install-recommends python3 
python3-pip python3-dev && "
+                        + "rm -rf /var/lib/apt/lists/* && "
+                        + "python3 -m pip install "
+                        + externallyManagedOption
+                        + "--disable-pip-version-check --no-cache-dir pemja=="
+                        + PEMJA_VERSION
+                        + " && "
+                        + CONTAINER_PYTHON_EXECUTABLE
+                        + " -c 'import pemja'";
+        Container.ExecResult result =
+                container.execInContainer(
+                        ExecConfig.builder()
+                                .user("root")
+                                .command(new String[] {"bash", "-c", script})
+                                .build());
+        if (result.getExitCode() != 0) {
+            throw new IllegalStateException(
+                    "Failed to install Pemja into "
+                            + container.getDockerImageName()
+                            + " (exit="
+                            + result.getExitCode()
+                            + ").\nstdout:\n"
+                            + result.getStdout()
+                            + "\nstderr:\n"
+                            + result.getStderr());
+        }
+    }
+
+    private void preparePythonFiles(GenericContainer<?> container) throws 
Exception {
+        runInContainerAsRoot(container, "mkdir", "-p", PYTHON_FILES_DIRECTORY);
+        container.copyFileToContainer(
+                Transferable.of(
+                        "def normalize_email(value):\n"
+                                + "    return None if value is None else 
value.strip().lower()\n"),
+                PYTHON_FILES_DIRECTORY + "/python_udf_helpers.py");
+    }
+
+    private static final class UdfEntry {
+        private final String name;
+        private final String source;
+        @Nullable private final String pythonFiles;
+
+        private UdfEntry(String name, String source, @Nullable String 
pythonFiles) {
+            this.name = name;
+            this.source = source;
+            this.pythonFiles = pythonFiles;
+        }
+    }
+}
diff --git 
a/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/resources/ddl/python_udf_test.sql
 
b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/resources/ddl/python_udf_test.sql
new file mode 100644
index 0000000000..fba07b4449
--- /dev/null
+++ 
b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/resources/ddl/python_udf_test.sql
@@ -0,0 +1,37 @@
+-- Licensed to the Apache Software Foundation (ASF) under one or more
+-- contributor license agreements.  See the NOTICE file distributed with
+-- this work for additional information regarding copyright ownership.
+-- The ASF licenses this file to You under the Apache License, Version 2.0
+-- (the "License"); you may not use this file except in compliance with
+-- the License.  You may obtain a copy of the License at
+--
+--      http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+-- Fixture for PythonUdfE2eITCase. Rows are intentionally messy (mixed case,
+-- whitespace, varied phone formats) so the Python UDFs have something to do
+-- that built-in transform expressions can't easily match.
+
+DROP TABLE IF EXISTS USERS;
+
+CREATE TABLE USERS (
+    ID INT NOT NULL,
+    EMAIL VARCHAR(128),
+    PHONE VARCHAR(64),
+    AGE INT,
+    SCORE DOUBLE,
+    ACTIVE BOOLEAN,
+    AVATAR VARBINARY(64),
+    PRIMARY KEY (ID)
+);
+
+INSERT INTO USERS VALUES (1, '[email protected]',  '+1 (415) 555-0100', 28, 
87.5, TRUE,  X'48656C6C6F');
+INSERT INTO USERS VALUES (2, '  [email protected]  ',    '(212)-555-0199',    25, 
64.0, FALSE, X'576F726C64');
+INSERT INTO USERS VALUES (3, '[email protected]',  '+44 20 7946 0958',  35, 
92.5, TRUE,  X'00FF7F');
+INSERT INTO USERS VALUES (4, '[email protected]',   '+1.617.555.0173',   42, 
78.0, TRUE,  X'CAFEBABE');
+INSERT INTO USERS VALUES (5, NULL,                 NULL,                NULL, 
NULL, NULL,  NULL);
diff --git 
a/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/resources/rules/unexpected.yaml
 
b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/resources/rules/unexpected.yaml
index 0b9257b1a9..b2a96ce011 100644
--- 
a/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/resources/rules/unexpected.yaml
+++ 
b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/resources/rules/unexpected.yaml
@@ -79,5 +79,5 @@ steps:
             language: clojure
     error: |
       Unexpected key `language` in YAML UDF block.
-      Allowed keys in this context are: [name, classpath, options]
+      Allowed keys in this context are: [name, classpath, python-code, 
python-executable, python-files, options]
       Note: option language: "clojure" is unexpected. It was silently ignored 
in previous versions, and probably should be removed.
diff --git a/flink-cdc-python/pom.xml b/flink-cdc-python/pom.xml
new file mode 100644
index 0000000000..5fc3f2666d
--- /dev/null
+++ b/flink-cdc-python/pom.xml
@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0";
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache.flink</groupId>
+        <artifactId>flink-cdc-parent</artifactId>
+        <version>${revision}</version>
+    </parent>
+
+    <artifactId>flink-cdc-python</artifactId>
+    <name>Flink CDC : Pipeline UDF : Python</name>
+
+    <properties>
+        <pemja.version>0.5.7</pemja.version>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.flink</groupId>
+            <artifactId>flink-cdc-common</artifactId>
+            <version>${project.version}</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>pemja</artifactId>
+            <version>${pemja.version}</version>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-shade-plugin</artifactId>
+                <version>${maven.shade.plugin.version}</version>
+                <executions>
+                    <execution>
+                        <id>shade-flink</id>
+                        <phase>package</phase>
+                        <goals>
+                            <goal>shade</goal>
+                        </goals>
+                        <configuration>
+                            <shadeTestJar>false</shadeTestJar>
+                            <artifactSet>
+                                <includes>
+                                    <include>com.alibaba:pemja</include>
+                                </includes>
+                            </artifactSet>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>
diff --git 
a/flink-cdc-python/src/main/java/org/apache/flink/cdc/python/PythonUdf.java 
b/flink-cdc-python/src/main/java/org/apache/flink/cdc/python/PythonUdf.java
new file mode 100644
index 0000000000..4eff12ea86
--- /dev/null
+++ b/flink-cdc-python/src/main/java/org/apache/flink/cdc/python/PythonUdf.java
@@ -0,0 +1,236 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.python;
+
+import org.apache.flink.cdc.common.annotation.Experimental;
+import org.apache.flink.cdc.common.configuration.ConfigOption;
+import org.apache.flink.cdc.common.configuration.ConfigOptions;
+import org.apache.flink.cdc.common.configuration.Configuration;
+import org.apache.flink.cdc.common.types.DataType;
+import org.apache.flink.cdc.common.udf.UserDefinedFunction;
+import org.apache.flink.cdc.common.udf.UserDefinedFunctionContext;
+import org.apache.flink.cdc.python.utils.PythonUdfSignature;
+
+import pemja.core.PythonInterpreter;
+import pemja.core.PythonInterpreterConfig;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+/** Generic UDF that delegates to a Python function defined inline in YAML. */
+@Experimental
+public final class PythonUdf implements UserDefinedFunction {
+
+    public static final ConfigOption<String> OPTION_SOURCE =
+            ConfigOptions.key("source")
+                    .stringType()
+                    .noDefaultValue()
+                    .withDescription("Inline Python source containing a `def 
eval(...)`.");
+
+    public static final ConfigOption<String> OPTION_PYTHON_EXECUTABLE =
+            ConfigOptions.key("python-executable")
+                    .stringType()
+                    .defaultValue("python3")
+                    .withDescription(
+                            "Path to the Python interpreter Pemja embeds on 
every TaskManager."
+                                    + " The interpreter must have a matching 
`pemja` package"
+                                    + " installed; defaults to the first 
`python3` on PATH.");
+
+    public static final ConfigOption<String> OPTION_PYTHON_FILES =
+            ConfigOptions.key("python-files")
+                    .stringType()
+                    .noDefaultValue()
+                    .withDescription(
+                            "Comma-separated directories or zip archives that 
will be added"
+                                    + " to the embedded Python import search 
path. Zip archives"
+                                    + " are extracted to a temporary directory 
first so packages"
+                                    + " with native extensions can be 
imported.");
+
+    private static final String PYTHON_FUNCTION_NAME = "eval";
+
+    private transient PythonInterpreter interpreter;
+    private transient Path extractedPythonFilesDirectory;
+
+    @Override
+    public void open(UserDefinedFunctionContext context) {
+        Configuration config = context.configuration();
+        String source = requireSource(config);
+        String pythonExec = config.get(OPTION_PYTHON_EXECUTABLE);
+
+        PythonInterpreterConfig.PythonInterpreterConfigBuilder 
pemjaConfigBuilder =
+                PythonInterpreterConfig.newBuilder().setPythonExec(pythonExec);
+        try {
+            configurePythonFiles(pemjaConfigBuilder, config);
+            this.interpreter = new 
PythonInterpreter(pemjaConfigBuilder.build());
+            this.interpreter.exec(source);
+        } catch (Exception | Error failure) {
+            try {
+                close();
+            } catch (Exception | Error cleanupFailure) {
+                failure.addSuppressed(cleanupFailure);
+            }
+            throw failure;
+        }
+    }
+
+    @Override
+    public void close() {
+        try {
+            if (interpreter != null) {
+                interpreter.close();
+            }
+        } finally {
+            interpreter = null;
+            cleanupExtractedPythonFiles();
+        }
+    }
+
+    @Override
+    public DataType getReturnType(UserDefinedFunctionContext context) {
+        Configuration config = context.configuration();
+        String source = requireSource(config);
+        return PythonUdfSignature.parseReturnType(source, 
config.get(OPTION_PYTHON_EXECUTABLE));
+    }
+
+    public Object eval(Object... args) {
+        if (interpreter == null) {
+            throw new IllegalStateException("PythonUdf invoked before open() 
was called.");
+        }
+        return interpreter.invoke(PYTHON_FUNCTION_NAME, args);
+    }
+
+    private void configurePythonFiles(
+            PythonInterpreterConfig.PythonInterpreterConfigBuilder 
pemjaConfigBuilder,
+            Configuration config) {
+        List<String> pythonPaths = new ArrayList<>();
+        for (String rawPath : 
config.getOptional(OPTION_PYTHON_FILES).orElse("").split(",")) {
+            String path = rawPath.trim();
+            if (path.isEmpty()) {
+                continue;
+            }
+            pythonPaths.add(resolvePythonFilePath(path));
+        }
+        if (!pythonPaths.isEmpty()) {
+            pemjaConfigBuilder.addPythonPaths(String.join(File.pathSeparator, 
pythonPaths));
+        }
+    }
+
+    private String resolvePythonFilePath(String configuredPath) {
+        Path path = new 
File(configuredPath).toPath().toAbsolutePath().normalize();
+        if (Files.isDirectory(path)) {
+            return path.toString();
+        }
+        if (Files.isRegularFile(path)
+                && 
path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".zip")) {
+            return extractPythonArchive(path).toString();
+        }
+        throw new IllegalArgumentException(
+                "Python UDF option '"
+                        + OPTION_PYTHON_FILES.key()
+                        + "' only supports existing directories or .zip 
archives, but got: "
+                        + configuredPath);
+    }
+
+    private Path extractPythonArchive(Path archivePath) {
+        try {
+            if (extractedPythonFilesDirectory == null) {
+                extractedPythonFilesDirectory = 
Files.createTempDirectory("python-udf-files-");
+            }
+            String archiveName = archivePath.getFileName().toString();
+            int suffixIndex = 
archiveName.toLowerCase(Locale.ROOT).lastIndexOf(".zip");
+            String targetDirectoryName =
+                    suffixIndex > 0 ? archiveName.substring(0, suffixIndex) : 
archiveName;
+            Path targetDirectory =
+                    Files.createTempDirectory(
+                            extractedPythonFilesDirectory, targetDirectoryName 
+ "-");
+            unzipArchive(archivePath, targetDirectory);
+            return targetDirectory;
+        } catch (IOException e) {
+            throw new IllegalArgumentException(
+                    "Failed to extract Python dependency archive: " + 
archivePath, e);
+        }
+    }
+
+    private static void unzipArchive(Path archivePath, Path targetDirectory) 
throws IOException {
+        try (InputStream inputStream = Files.newInputStream(archivePath);
+                ZipInputStream zipInputStream = new 
ZipInputStream(inputStream)) {
+            ZipEntry zipEntry;
+            while ((zipEntry = zipInputStream.getNextEntry()) != null) {
+                Path targetPath = 
targetDirectory.resolve(zipEntry.getName()).normalize();
+                if (!targetPath.startsWith(targetDirectory)) {
+                    throw new IOException(
+                            "Zip entry escapes extraction directory: " + 
zipEntry.getName());
+                }
+                if (zipEntry.isDirectory()) {
+                    Files.createDirectories(targetPath);
+                } else {
+                    Path parent = targetPath.getParent();
+                    if (parent != null) {
+                        Files.createDirectories(parent);
+                    }
+                    Files.copy(zipInputStream, targetPath, 
StandardCopyOption.REPLACE_EXISTING);
+                }
+                zipInputStream.closeEntry();
+            }
+        }
+    }
+
+    private void cleanupExtractedPythonFiles() {
+        if (extractedPythonFilesDirectory == null) {
+            return;
+        }
+        try {
+            Path[] filesToDelete;
+            try (Stream<Path> files = 
Files.walk(extractedPythonFilesDirectory)) {
+                filesToDelete = 
files.sorted(Comparator.reverseOrder()).toArray(Path[]::new);
+            }
+            for (Path path : filesToDelete) {
+                try {
+                    Files.deleteIfExists(path);
+                } catch (IOException ignored) {
+                    // Best-effort cleanup only.
+                }
+            }
+        } catch (IOException ignored) {
+            // Best-effort cleanup only.
+        } finally {
+            extractedPythonFilesDirectory = null;
+        }
+    }
+
+    private static String requireSource(Configuration config) {
+        return config.getOptional(OPTION_SOURCE)
+                .orElseThrow(
+                        () ->
+                                new IllegalArgumentException(
+                                        "Python UDF is missing required option 
'"
+                                                + OPTION_SOURCE.key()
+                                                + "'."));
+    }
+}
diff --git 
a/flink-cdc-python/src/main/java/org/apache/flink/cdc/python/utils/PythonUdfSignature.java
 
b/flink-cdc-python/src/main/java/org/apache/flink/cdc/python/utils/PythonUdfSignature.java
new file mode 100644
index 0000000000..a9a3976aa5
--- /dev/null
+++ 
b/flink-cdc-python/src/main/java/org/apache/flink/cdc/python/utils/PythonUdfSignature.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.python.utils;
+
+import org.apache.flink.cdc.common.types.DataType;
+import org.apache.flink.cdc.common.types.DataTypes;
+
+import pemja.core.PythonInterpreter;
+import pemja.core.PythonInterpreterConfig;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/** Resolves the return type of a {@code PythonUdf} from its inline Python 
source. */
+public final class PythonUdfSignature {
+
+    private static final String SIGNATURE_PARSER_RESOURCE =
+            "/org/apache/flink/cdc/python/signature.py";
+
+    private static final Map<String, DataType> SUPPORTED_ANNOTATIONS =
+            Map.of(
+                    "bool", DataTypes.BOOLEAN(),
+                    "int", DataTypes.BIGINT(),
+                    "float", DataTypes.DOUBLE(),
+                    "str", DataTypes.STRING(),
+                    "bytes", DataTypes.BYTES());
+
+    private PythonUdfSignature() {}
+
+    public static DataType parseReturnType(String source, String pythonExec) {
+        String annotation = parseReturnAnnotation(source, pythonExec).trim();
+        DataType type = SUPPORTED_ANNOTATIONS.get(annotation);
+        if (type == null) {
+            throw new IllegalArgumentException(
+                    "Unsupported Python UDF return type '"
+                            + annotation
+                            + "'. Supported annotations: "
+                            + SUPPORTED_ANNOTATIONS.keySet().stream()
+                                    .sorted()
+                                    .collect(Collectors.toList()));
+        }
+        return type;
+    }
+
+    private static String parseReturnAnnotation(String source, String 
pythonExec) {
+        PythonInterpreterConfig pemjaConfig =
+                
PythonInterpreterConfig.newBuilder().setPythonExec(pythonExec).build();
+        try (PythonInterpreter parser = new PythonInterpreter(pemjaConfig)) {
+            parser.exec(loadSignatureScript());
+            return (String) parser.invoke("eval_return_type", source);
+        }
+    }
+
+    private static String loadSignatureScript() {
+        try (InputStream in =
+                
PythonUdfSignature.class.getResourceAsStream(SIGNATURE_PARSER_RESOURCE)) {
+            if (in == null) {
+                throw new IllegalStateException(
+                        "Bundled signature parser resource not found: "
+                                + SIGNATURE_PARSER_RESOURCE);
+            }
+            return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            throw new IllegalArgumentException("Failed to parse Python UDF 
signature", e);
+        }
+    }
+}
diff --git 
a/flink-cdc-python/src/main/resources/org/apache/flink/cdc/python/signature.py 
b/flink-cdc-python/src/main/resources/org/apache/flink/cdc/python/signature.py
new file mode 100644
index 0000000000..9bdc11f784
--- /dev/null
+++ 
b/flink-cdc-python/src/main/resources/org/apache/flink/cdc/python/signature.py
@@ -0,0 +1,54 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+################################################################################
+"""Resolve the Calcite return type of a Python UDF from its inline source."""
+
+import ast
+
+
+def eval_return_type(source):
+    """Return the raw return-type annotation string of the top-level ``eval``
+    function. Raises ``ValueError`` if it can't be determined.
+    """
+    for node in ast.parse(source).body:
+        if isinstance(node, ast.FunctionDef) and node.name == 'eval':
+            if node.returns is None:
+                raise ValueError(
+                    "Function 'eval' has no return type annotation."
+                )
+            annotation = _annotation_string(node.returns)
+            if annotation is None:
+                raise ValueError(
+                    "Return type annotation of 'eval' could not be rendered "
+                    "(needs Python 3.9+ for non-trivial annotations)."
+                )
+            return annotation
+    raise ValueError(
+        "Python UDF source does not define a top-level 'eval' function."
+    )
+
+
+def _annotation_string(annotation):
+    if isinstance(annotation, ast.Name):
+        return annotation.id
+    unparse = getattr(ast, 'unparse', None)
+    if unparse is None:
+        return None
+    try:
+        return unparse(annotation)
+    except (AttributeError, TypeError):
+        return None
diff --git 
a/flink-cdc-python/src/test/java/org/apache/flink/cdc/python/PythonUdfTest.java 
b/flink-cdc-python/src/test/java/org/apache/flink/cdc/python/PythonUdfTest.java
new file mode 100644
index 0000000000..fdff958616
--- /dev/null
+++ 
b/flink-cdc-python/src/test/java/org/apache/flink/cdc/python/PythonUdfTest.java
@@ -0,0 +1,219 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.python;
+
+import org.apache.flink.cdc.common.configuration.Configuration;
+import org.apache.flink.cdc.common.types.DataTypes;
+import org.apache.flink.cdc.common.udf.UserDefinedFunctionContext;
+import org.apache.flink.cdc.python.utils.PemjaTestSupport;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link PythonUdf}. */
+class PythonUdfTest {
+
+    @BeforeAll
+    static void requirePemja() {
+        PemjaTestSupport.requirePemja();
+    }
+
+    @Test
+    void evalBeforeOpenThrows() {
+        PythonUdf udf = new PythonUdf();
+        assertThatThrownBy(() -> udf.eval(1L))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("before open()");
+    }
+
+    @Test
+    void closeIsIdempotent() throws Exception {
+        PythonUdf udf = new PythonUdf();
+        udf.close();
+        udf.close();
+        try {
+            udf.open(contextFor("def eval(x: int) -> int:\n    return x\n"));
+            udf.close();
+            udf.close();
+        } finally {
+            udf.close();
+        }
+    }
+
+    @Test
+    void openRequiresSourceOption() {
+        PythonUdf udf = new PythonUdf();
+        Map<String, String> opts = new HashMap<>();
+        opts.put(PythonUdf.OPTION_PYTHON_EXECUTABLE.key(), 
PemjaTestSupport.PYTHON_EXEC);
+        assertThatThrownBy(() -> udf.open(() -> Configuration.fromMap(opts)))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining(PythonUdf.OPTION_SOURCE.key());
+    }
+
+    @Test
+    void getReturnTypeReadsFromSource() {
+        PythonUdf udf = new PythonUdf();
+        assertThat(udf.getReturnType(contextFor("def eval(x: int) -> int:\n    
return x * 2\n")))
+                .isEqualTo(DataTypes.BIGINT());
+    }
+
+    @Test
+    void evalRoundTripsInt() throws Exception {
+        PythonUdf udf = new PythonUdf();
+        udf.open(contextFor("def eval(x: int) -> int:\n    return x * 2\n"));
+        try {
+            assertThat(udf.eval(21L)).isEqualTo(42L);
+        } finally {
+            udf.close();
+        }
+    }
+
+    @Test
+    void evalRoundTripsString() throws Exception {
+        PythonUdf udf = new PythonUdf();
+        udf.open(contextFor("def eval(s: str) -> str:\n    return 
s.upper()\n"));
+        try {
+            assertThat(udf.eval("abc")).isEqualTo("ABC");
+        } finally {
+            udf.close();
+        }
+    }
+
+    @Test
+    void evalForwardsNullToPython() throws Exception {
+        // Pemja maps Java null -> Python None; a guard-clause UDF should see 
it and may handle it.
+        PythonUdf udf = new PythonUdf();
+        udf.open(
+                contextFor(
+                        "def eval(s) -> str:\n" + "    return 'null' if s is 
None else str(s)\n"));
+        try {
+            assertThat(udf.eval(new Object[] {null})).isEqualTo("null");
+        } finally {
+            udf.close();
+        }
+    }
+
+    @Test
+    void evalImportsModuleFromDirectory(@TempDir Path tempDir) throws 
Exception {
+        Path moduleDir = tempDir.resolve("python-dir");
+        Files.createDirectories(moduleDir);
+        Files.write(
+                moduleDir.resolve("helper_mod.py"),
+                ("def twice(x):\n" + "    return x * 
2\n").getBytes(StandardCharsets.UTF_8));
+
+        PythonUdf udf = new PythonUdf();
+        udf.open(
+                contextFor(
+                        "import helper_mod\n"
+                                + "def eval(x: int) -> int:\n"
+                                + "    return helper_mod.twice(x)\n",
+                        moduleDir.toString()));
+        try {
+            assertThat(udf.eval(21L)).isEqualTo(42L);
+        } finally {
+            udf.close();
+        }
+    }
+
+    @Test
+    void evalImportsModuleFromZip(@TempDir Path tempDir) throws Exception {
+        Path zipFile = tempDir.resolve("python-deps.zip");
+        try (ZipOutputStream zipOutputStream =
+                new ZipOutputStream(Files.newOutputStream(zipFile))) {
+            zipOutputStream.putNextEntry(new ZipEntry("helper_zip.py"));
+            zipOutputStream.write(
+                    ("def shout(s):\n" + "    return s.upper()\n")
+                            .getBytes(StandardCharsets.UTF_8));
+            zipOutputStream.closeEntry();
+        }
+
+        PythonUdf udf = new PythonUdf();
+        udf.open(
+                contextFor(
+                        "import helper_zip\n"
+                                + "def eval(s: str) -> str:\n"
+                                + "    return helper_zip.shout(s)\n",
+                        zipFile.toString()));
+        try {
+            assertThat(udf.eval("abc")).isEqualTo("ABC");
+        } finally {
+            udf.close();
+        }
+    }
+
+    @Test
+    void openFailureCleansUpInterpreterAndExtractedPythonFiles(@TempDir Path 
tempDir)
+            throws Exception {
+        Path zipFile = tempDir.resolve("python-deps.zip");
+        try (ZipOutputStream zipOutputStream =
+                new ZipOutputStream(Files.newOutputStream(zipFile))) {
+            zipOutputStream.putNextEntry(new ZipEntry("helper.py"));
+            zipOutputStream.write("VALUE = 
1\n".getBytes(StandardCharsets.UTF_8));
+            zipOutputStream.closeEntry();
+        }
+
+        Path extractedPathMarker = tempDir.resolve("extracted-path.txt");
+        String markerPath =
+                extractedPathMarker.toString().replace("\\", 
"\\\\").replace("'", "\\'");
+        String source =
+                "import sys\n"
+                        + "from pathlib import Path\n"
+                        + "extracted = next(p for p in sys.path if 
'python-udf-files-' in p)\n"
+                        + "Path('"
+                        + markerPath
+                        + "').write_text(extracted)\n"
+                        + "raise RuntimeError('expected open failure')\n";
+
+        PythonUdf udf = new PythonUdf();
+        assertThatThrownBy(() -> udf.open(contextFor(source, 
zipFile.toString())))
+                .hasMessageContaining("expected open failure");
+
+        Path extractedPath = Paths.get(Files.readString(extractedPathMarker));
+        assertThat(extractedPath).doesNotExist();
+        assertThat(udf)
+                .extracting("interpreter", "extractedPythonFilesDirectory")
+                .containsExactly(null, null);
+    }
+
+    private static UserDefinedFunctionContext contextFor(String source) {
+        return contextFor(source, null);
+    }
+
+    private static UserDefinedFunctionContext contextFor(String source, String 
pythonFiles) {
+        Map<String, String> opts = new HashMap<>();
+        opts.put(PythonUdf.OPTION_SOURCE.key(), source);
+        opts.put(PythonUdf.OPTION_PYTHON_EXECUTABLE.key(), 
PemjaTestSupport.PYTHON_EXEC);
+        if (pythonFiles != null) {
+            opts.put(PythonUdf.OPTION_PYTHON_FILES.key(), pythonFiles);
+        }
+        return () -> Configuration.fromMap(opts);
+    }
+}
diff --git 
a/flink-cdc-python/src/test/java/org/apache/flink/cdc/python/utils/PemjaTestSupport.java
 
b/flink-cdc-python/src/test/java/org/apache/flink/cdc/python/utils/PemjaTestSupport.java
new file mode 100644
index 0000000000..e852da68a3
--- /dev/null
+++ 
b/flink-cdc-python/src/test/java/org/apache/flink/cdc/python/utils/PemjaTestSupport.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.python.utils;
+
+import pemja.core.PythonInterpreter;
+import pemja.core.PythonInterpreterConfig;
+
+/**
+ * Fails fast with an actionable message if Pemja or its Python sidecar isn't 
reachable. The
+ * interpreter is resolved from {@code pemja.python.executable} (system 
property) or {@code
+ * PEMJA_PYTHON_EXECUTABLE} (env var), falling back to {@code python3} on PATH.
+ */
+public final class PemjaTestSupport {
+
+    public static final String PYTHON_EXEC =
+            System.getProperty(
+                    "pemja.python.executable",
+                    System.getenv().getOrDefault("PEMJA_PYTHON_EXECUTABLE", 
"python3"));
+
+    private PemjaTestSupport() {}
+
+    public static void requirePemja() {
+        try (PythonInterpreter ignored =
+                new PythonInterpreter(
+                        
PythonInterpreterConfig.newBuilder().setPythonExec(PYTHON_EXEC).build())) {
+            // Smoke-test: starting the interpreter loads libpython + the 
pemja package.
+        } catch (Throwable t) {
+            throw new IllegalStateException(
+                    "Pemja could not be initialized using '"
+                            + PYTHON_EXEC
+                            + "'. Install Python 3.9+ and `pip install 
pemja==0.5.7`, or point"
+                            + " the tests at a usable interpreter via"
+                            + " -Dpemja.python.executable=/path/to/python3"
+                            + " (or PEMJA_PYTHON_EXECUTABLE env var).",
+                    t);
+        }
+    }
+}
diff --git 
a/flink-cdc-python/src/test/java/org/apache/flink/cdc/python/utils/PythonUdfSignatureTest.java
 
b/flink-cdc-python/src/test/java/org/apache/flink/cdc/python/utils/PythonUdfSignatureTest.java
new file mode 100644
index 0000000000..090f8e2ac0
--- /dev/null
+++ 
b/flink-cdc-python/src/test/java/org/apache/flink/cdc/python/utils/PythonUdfSignatureTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.python.utils;
+
+import org.apache.flink.cdc.common.types.DataTypes;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link PythonUdfSignature}. */
+class PythonUdfSignatureTest {
+
+    @BeforeAll
+    static void requirePemja() {
+        PemjaTestSupport.requirePemja();
+    }
+
+    @Test
+    void resolvesEvenWhenOtherFunctionsArePresent() {
+        String source =
+                "def helper(x: int) -> int:\n"
+                        + "    return x\n"
+                        + "def eval(x: str) -> str:\n"
+                        + "    return helper(len(x)) and x\n";
+        assertThat(PythonUdfSignature.parseReturnType(source, 
PemjaTestSupport.PYTHON_EXEC))
+                .isEqualTo(DataTypes.STRING());
+    }
+
+    @Test
+    void throwsWhenNoEvalFunction() {
+        String source = "def other(x: int) -> int:\n    return x\n";
+        assertThatThrownBy(
+                        () ->
+                                PythonUdfSignature.parseReturnType(
+                                        source, PemjaTestSupport.PYTHON_EXEC))
+                .hasMessageContaining(
+                        "Python UDF source does not define a top-level 'eval' 
function.");
+    }
+
+    @Test
+    void throwsWhenEvalHasNoReturnAnnotation() {
+        String source = "def eval(x: int):\n    return x\n";
+        assertThatThrownBy(
+                        () ->
+                                PythonUdfSignature.parseReturnType(
+                                        source, PemjaTestSupport.PYTHON_EXEC))
+                .hasMessageContaining("Function 'eval' has no return type 
annotation.");
+    }
+
+    @Test
+    void throwsWhenAnnotationUnsupported() {
+        String source = "def eval(x: int) -> bytearray:\n    return 
bytearray(x)\n";
+        assertThatThrownBy(
+                        () ->
+                                PythonUdfSignature.parseReturnType(
+                                        source, PemjaTestSupport.PYTHON_EXEC))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Unsupported Python UDF return type 
'bytearray'.")
+                .hasMessageContaining("Supported annotations: [bool, bytes, 
float, int, str]");
+    }
+
+    @Test
+    void throwsOnInvalidPythonSource() {
+        // Not "no eval" — outright syntax error so ast.parse blows up inside 
the parser.
+        String source = "def eval(x ->\n";
+        assertThatThrownBy(
+                        () ->
+                                PythonUdfSignature.parseReturnType(
+                                        source, PemjaTestSupport.PYTHON_EXEC))
+                .hasMessageContaining("invalid syntax");
+    }
+}
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/transform/TransformExpressionCompiler.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/transform/TransformExpressionCompiler.java
index 776b4af2d0..47e42eb90b 100644
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/transform/TransformExpressionCompiler.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/transform/TransformExpressionCompiler.java
@@ -81,7 +81,7 @@ public class TransformExpressionCompiler {
                         List<Class<?>> argumentClasses = new 
ArrayList<>(key.getArgumentClasses());
 
                         for (UserDefinedFunctionDescriptor udfFunction : 
udfDescriptors) {
-                            argumentNames.add("__instanceOf" + 
udfFunction.getClassName());
+                            argumentNames.add("__udf_" + 
udfFunction.getName());
                             
argumentClasses.add(Class.forName(udfFunction.getClasspath()));
                         }
 
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/transform/UserDefinedFunctionDescriptor.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/transform/UserDefinedFunctionDescriptor.java
index 4e792e8719..4df805f88b 100644
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/transform/UserDefinedFunctionDescriptor.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/transform/UserDefinedFunctionDescriptor.java
@@ -19,8 +19,10 @@ package org.apache.flink.cdc.runtime.operators.transform;
 
 import org.apache.flink.api.java.tuple.Tuple3;
 import org.apache.flink.cdc.common.annotation.Internal;
+import org.apache.flink.cdc.common.configuration.Configuration;
 import org.apache.flink.cdc.common.types.DataType;
 import org.apache.flink.cdc.common.udf.UserDefinedFunction;
+import org.apache.flink.cdc.common.udf.UserDefinedFunctionContext;
 
 import java.io.Serializable;
 import java.lang.reflect.InvocationTargetException;
@@ -62,13 +64,10 @@ public class UserDefinedFunctionDescriptor implements 
Serializable {
             Class<?> clazz = Class.forName(classpath);
             isCdcPipelineUdf = isCdcPipelineUdf(clazz);
             if (isCdcPipelineUdf) {
-                // We use reflection to invoke UDF methods since we may add 
more methods
-                // into UserDefinedFunction interface, thus the provided UDF 
classes
-                // might not be compatible with the interface definition in 
CDC common.
+                UserDefinedFunctionContext context = () -> 
Configuration.fromMap(parameters);
                 returnTypeHint =
-                        (DataType)
-                                clazz.getMethod("getReturnType")
-                                        
.invoke(clazz.getConstructor().newInstance());
+                        ((UserDefinedFunction) 
clazz.getConstructor().newInstance())
+                                .getReturnType(context);
             } else {
                 returnTypeHint = null;
             }
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/parser/JaninoCompiler.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/parser/JaninoCompiler.java
index d03664a35e..1497d6cda2 100644
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/parser/JaninoCompiler.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/parser/JaninoCompiler.java
@@ -1256,12 +1256,12 @@ public class JaninoCompiler {
     private static String 
generateInvokeExpression(UserDefinedFunctionDescriptor udfFunction) {
         if (udfFunction.getReturnTypeHint() != null) {
             return String.format(
-                    "(%s) __instanceOf%s.eval",
+                    "(%s) __udf_%s.eval",
                     
JavaClassConverter.toJavaClass(udfFunction.getReturnTypeHint())
                             .getCanonicalName(),
-                    udfFunction.getClassName());
+                    udfFunction.getName());
         } else {
-            return String.format("__instanceOf%s.eval", 
udfFunction.getClassName());
+            return String.format("__udf_%s.eval", udfFunction.getName());
         }
     }
 
diff --git 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/transform/UserDefinedFunctionDescriptorTest.java
 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/transform/UserDefinedFunctionDescriptorTest.java
index 79f9c92af5..3ca5ecac15 100644
--- 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/transform/UserDefinedFunctionDescriptorTest.java
+++ 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/transform/UserDefinedFunctionDescriptorTest.java
@@ -20,12 +20,15 @@ package org.apache.flink.cdc.runtime.operators.transform;
 import org.apache.flink.cdc.common.types.DataType;
 import org.apache.flink.cdc.common.types.DataTypes;
 import org.apache.flink.cdc.common.udf.UserDefinedFunction;
+import org.apache.flink.cdc.common.udf.UserDefinedFunctionContext;
 import org.apache.flink.cdc.runtime.model.OpenAIEmbeddingModel;
 import org.apache.flink.table.functions.ScalarFunction;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import org.junit.jupiter.api.Test;
 
+import java.util.Collections;
+
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
@@ -43,6 +46,16 @@ class UserDefinedFunctionDescriptorTest {
         }
     }
 
+    /** This is a Flink CDC UDF whose type hint depends on per-UDF options. */
+    public static class CdcUdfWithOptionTypeHint implements 
UserDefinedFunction {
+        @Override
+        public DataType getReturnType(UserDefinedFunctionContext context) {
+            return 
"string".equals(context.configuration().toMap().get("return-type"))
+                    ? DataTypes.STRING()
+                    : DataTypes.BIGINT();
+        }
+    }
+
     /** This is a Flink ScalarFunction. */
     public static class FlinkUdf extends ScalarFunction {}
 
@@ -105,4 +118,15 @@ class UserDefinedFunctionDescriptorTest {
                         DataTypes.ARRAY(DataTypes.FLOAT()),
                         true);
     }
+
+    @Test
+    void testReturnTypeHintUsesUdfOptions() {
+        UserDefinedFunctionDescriptor descriptor =
+                new UserDefinedFunctionDescriptor(
+                        "option_type_hint",
+                        CdcUdfWithOptionTypeHint.class.getName(),
+                        Collections.singletonMap("return-type", "string"));
+
+        
assertThat(descriptor.getReturnTypeHint()).isEqualTo(DataTypes.STRING());
+    }
 }
diff --git 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/parser/TransformParserTest.java
 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/parser/TransformParserTest.java
index 16136323f7..50fcc1e455 100644
--- 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/parser/TransformParserTest.java
+++ 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/parser/TransformParserTest.java
@@ -1166,50 +1166,34 @@ class TransformParserTest {
     void testTranslateUdfFilterToJaninoExpression() {
         testFilterExpressionWithUdf(
                 "IFNULL(format(id), 'fallback')",
-                "(java.lang.String) 
ifNull(__instanceOfFormatFunctionClass.eval(id), \"fallback\")");
+                "(java.lang.String) ifNull(__udf_format.eval(id), 
\"fallback\")");
         testFilterExpressionWithUdf(
                 "NULLIF(format(id), '1')",
-                "(java.lang.String) 
nullIf(__instanceOfFormatFunctionClass.eval(id), \"1\")");
+                "(java.lang.String) nullIf(__udf_format.eval(id), \"1\")");
+        testFilterExpressionWithUdf("format(upper(id))", 
"__udf_format.eval(upper(id))");
+        testFilterExpressionWithUdf("format(lower(id))", 
"__udf_format.eval(lower(id))");
+        testFilterExpressionWithUdf("format(concat(a,b))", 
"__udf_format.eval(concat(a, b))");
+        testFilterExpressionWithUdf("format(SUBSTR(a,1))", 
"__udf_format.eval(substr(a, 1))");
         testFilterExpressionWithUdf(
-                "format(upper(id))", 
"__instanceOfFormatFunctionClass.eval(upper(id))");
+                "typeof(id like '^[a-zA-Z]')", "__udf_typeof.eval(like(id, 
\"^[a-zA-Z]\"))");
         testFilterExpressionWithUdf(
-                "format(lower(id))", 
"__instanceOfFormatFunctionClass.eval(lower(id))");
-        testFilterExpressionWithUdf(
-                "format(concat(a,b))", 
"__instanceOfFormatFunctionClass.eval(concat(a, b))");
-        testFilterExpressionWithUdf(
-                "format(SUBSTR(a,1))", 
"__instanceOfFormatFunctionClass.eval(substr(a, 1))");
-        testFilterExpressionWithUdf(
-                "typeof(id like '^[a-zA-Z]')",
-                "__instanceOfTypeOfFunctionClass.eval(like(id, 
\"^[a-zA-Z]\"))");
-        testFilterExpressionWithUdf(
-                "typeof(id not like '^[a-zA-Z]')",
-                "__instanceOfTypeOfFunctionClass.eval(notLike(id, 
\"^[a-zA-Z]\"))");
-        testFilterExpressionWithUdf(
-                "typeof(abs(2))", 
"__instanceOfTypeOfFunctionClass.eval(abs(2))");
-        testFilterExpressionWithUdf(
-                "typeof(ceil(2))", 
"__instanceOfTypeOfFunctionClass.eval(ceil(2))");
-        testFilterExpressionWithUdf(
-                "typeof(ceiling(2))", 
"__instanceOfTypeOfFunctionClass.eval(ceil(2))");
-        testFilterExpressionWithUdf(
-                "typeof(floor(2))", 
"__instanceOfTypeOfFunctionClass.eval(floor(2))");
-        testFilterExpressionWithUdf(
-                "typeof(round(2,2))", 
"__instanceOfTypeOfFunctionClass.eval(round(2, 2))");
-        testFilterExpressionWithUdf(
-                "typeof(id + 2)", "__instanceOfTypeOfFunctionClass.eval(id + 
2)");
-        testFilterExpressionWithUdf(
-                "typeof(id - 2)", "__instanceOfTypeOfFunctionClass.eval(id - 
2)");
-        testFilterExpressionWithUdf(
-                "typeof(id * 2)", "__instanceOfTypeOfFunctionClass.eval(id * 
2)");
-        testFilterExpressionWithUdf(
-                "typeof(id / 2)", "__instanceOfTypeOfFunctionClass.eval(id / 
2)");
-        testFilterExpressionWithUdf(
-                "typeof(id % 2)", "__instanceOfTypeOfFunctionClass.eval(id % 
2)");
+                "typeof(id not like '^[a-zA-Z]')", 
"__udf_typeof.eval(notLike(id, \"^[a-zA-Z]\"))");
+        testFilterExpressionWithUdf("typeof(abs(2))", 
"__udf_typeof.eval(abs(2))");
+        testFilterExpressionWithUdf("typeof(ceil(2))", 
"__udf_typeof.eval(ceil(2))");
+        testFilterExpressionWithUdf("typeof(ceiling(2))", 
"__udf_typeof.eval(ceil(2))");
+        testFilterExpressionWithUdf("typeof(floor(2))", 
"__udf_typeof.eval(floor(2))");
+        testFilterExpressionWithUdf("typeof(round(2,2))", 
"__udf_typeof.eval(round(2, 2))");
+        testFilterExpressionWithUdf("typeof(id + 2)", "__udf_typeof.eval(id + 
2)");
+        testFilterExpressionWithUdf("typeof(id - 2)", "__udf_typeof.eval(id - 
2)");
+        testFilterExpressionWithUdf("typeof(id * 2)", "__udf_typeof.eval(id * 
2)");
+        testFilterExpressionWithUdf("typeof(id / 2)", "__udf_typeof.eval(id / 
2)");
+        testFilterExpressionWithUdf("typeof(id % 2)", "__udf_typeof.eval(id % 
2)");
         testFilterExpressionWithUdf(
                 "addone(addone(id)) > 4 OR typeof(id) <> 'bool' AND 
format('from %s to %s is %s', 'a', 'z', 'lie') <> ''",
-                
"greaterThan(__instanceOfAddOneFunctionClass.eval(__instanceOfAddOneFunctionClass.eval(id)),
 4) || !valueEquals(__instanceOfTypeOfFunctionClass.eval(id), \"bool\") && 
!valueEquals(__instanceOfFormatFunctionClass.eval(\"from %s to %s is %s\", 
\"a\", \"z\", \"lie\"), \"\")");
+                "greaterThan(__udf_addone.eval(__udf_addone.eval(id)), 4) || 
!valueEquals(__udf_typeof.eval(id), \"bool\") && 
!valueEquals(__udf_format.eval(\"from %s to %s is %s\", \"a\", \"z\", \"lie\"), 
\"\")");
         testFilterExpressionWithUdf(
                 "ADDONE(ADDONE(id)) > 4 OR TYPEOF(id) <> 'bool' AND 
FORMAT('from %s to %s is %s', 'a', 'z', 'lie') <> ''",
-                
"greaterThan(__instanceOfAddOneFunctionClass.eval(__instanceOfAddOneFunctionClass.eval(id)),
 4) || !valueEquals(__instanceOfTypeOfFunctionClass.eval(id), \"bool\") && 
!valueEquals(__instanceOfFormatFunctionClass.eval(\"from %s to %s is %s\", 
\"a\", \"z\", \"lie\"), \"\")");
+                "greaterThan(__udf_addone.eval(__udf_addone.eval(id)), 4) || 
!valueEquals(__udf_typeof.eval(id), \"bool\") && 
!valueEquals(__udf_format.eval(\"from %s to %s is %s\", \"a\", \"z\", \"lie\"), 
\"\")");
     }
 
     @Test
@@ -1228,19 +1212,19 @@ class TransformParserTest {
 
         testFilterExpressionWithUdf(
                 "IFNULL(id)",
-                "__instanceOfAddOneFunctionClass.eval(id)",
+                "__udf_ifnull.eval(id)",
                 DUMMY_COLUMNS,
                 Collections.emptyMap(),
                 udfDescriptors);
         testFilterExpressionWithUdf(
                 "TRY_CAST(id)",
-                "__instanceOfTypeOfFunctionClass.eval(id)",
+                "__udf_try_cast.eval(id)",
                 DUMMY_COLUMNS,
                 Collections.emptyMap(),
                 udfDescriptors);
         testFilterExpressionWithUdf(
                 "NULLIF('%s', 'udf')",
-                "__instanceOfFormatFunctionClass.eval(\"%s\", \"udf\")",
+                "__udf_nullif.eval(\"%s\", \"udf\")",
                 DUMMY_COLUMNS,
                 Collections.emptyMap(),
                 udfDescriptors);
@@ -1260,53 +1244,38 @@ class TransformParserTest {
         columnNameMap.put("a-b", "$2");
 
         testFilterExpressionWithUdf(
-                "format(upper(a))",
-                "__instanceOfFormatFunctionClass.eval(upper($0))",
-                columns,
-                columnNameMap);
+                "format(upper(a))", "__udf_format.eval(upper($0))", columns, 
columnNameMap);
         testFilterExpressionWithUdf(
-                "format(lower(b))",
-                "__instanceOfFormatFunctionClass.eval(lower($1))",
-                columns,
-                columnNameMap);
+                "format(lower(b))", "__udf_format.eval(lower($1))", columns, 
columnNameMap);
         testFilterExpressionWithUdf(
-                "format(concat(a,b))",
-                "__instanceOfFormatFunctionClass.eval(concat($0, $1))",
-                columns,
-                columnNameMap);
+                "format(concat(a,b))", "__udf_format.eval(concat($0, $1))", 
columns, columnNameMap);
         testFilterExpressionWithUdf(
                 "format(SUBSTR(`a-b`,1))",
-                "__instanceOfFormatFunctionClass.eval(substr($2, 1))",
+                "__udf_format.eval(substr($2, 1))",
                 columns,
                 columnNameMap);
         testFilterExpressionWithUdf(
                 "typeof(`a-b` like '^[a-zA-Z]')",
-                "__instanceOfTypeOfFunctionClass.eval(like($2, 
\"^[a-zA-Z]\"))",
+                "__udf_typeof.eval(like($2, \"^[a-zA-Z]\"))",
                 columns,
                 columnNameMap);
         testFilterExpressionWithUdf(
                 "typeof(`a-b` not like '^[a-zA-Z]')",
-                "__instanceOfTypeOfFunctionClass.eval(notLike($2, 
\"^[a-zA-Z]\"))",
+                "__udf_typeof.eval(notLike($2, \"^[a-zA-Z]\"))",
                 columns,
                 columnNameMap);
         testFilterExpressionWithUdf(
-                "typeof(a-b-`a-b`)",
-                "__instanceOfTypeOfFunctionClass.eval($0 - $1 - $2)",
-                columns,
-                columnNameMap);
+                "typeof(a-b-`a-b`)", "__udf_typeof.eval($0 - $1 - $2)", 
columns, columnNameMap);
         testFilterExpressionWithUdf(
-                "typeof(a-b-2)",
-                "__instanceOfTypeOfFunctionClass.eval($0 - $1 - 2)",
-                columns,
-                columnNameMap);
+                "typeof(a-b-2)", "__udf_typeof.eval($0 - $1 - 2)", columns, 
columnNameMap);
         testFilterExpressionWithUdf(
                 "addone(addone(`a-b`)) > 4 OR typeof(a-b) <> 'bool' AND 
format('from %s to %s is %s', 'a', 'z', 'lie') <> ''",
-                
"greaterThan(__instanceOfAddOneFunctionClass.eval(__instanceOfAddOneFunctionClass.eval($2)),
 4) || !valueEquals(__instanceOfTypeOfFunctionClass.eval($0 - $1), \"bool\") && 
!valueEquals(__instanceOfFormatFunctionClass.eval(\"from %s to %s is %s\", 
\"a\", \"z\", \"lie\"), \"\")",
+                "greaterThan(__udf_addone.eval(__udf_addone.eval($2)), 4) || 
!valueEquals(__udf_typeof.eval($0 - $1), \"bool\") && 
!valueEquals(__udf_format.eval(\"from %s to %s is %s\", \"a\", \"z\", \"lie\"), 
\"\")",
                 columns,
                 columnNameMap);
         testFilterExpressionWithUdf(
                 "ADDONE(ADDONE(`a-b`)) > 4 OR TYPEOF(a-b) <> 'bool' AND 
FORMAT('from %s to %s is %s', 'a', 'z', 'lie') <> ''",
-                
"greaterThan(__instanceOfAddOneFunctionClass.eval(__instanceOfAddOneFunctionClass.eval($2)),
 4) || !valueEquals(__instanceOfTypeOfFunctionClass.eval($0 - $1), \"bool\") && 
!valueEquals(__instanceOfFormatFunctionClass.eval(\"from %s to %s is %s\", 
\"a\", \"z\", \"lie\"), \"\")",
+                "greaterThan(__udf_addone.eval(__udf_addone.eval($2)), 4) || 
!valueEquals(__udf_typeof.eval($0 - $1), \"bool\") && 
!valueEquals(__udf_format.eval(\"from %s to %s is %s\", \"a\", \"z\", \"lie\"), 
\"\")",
                 columns,
                 columnNameMap);
     }
diff --git a/pom.xml b/pom.xml
index 54e5f69c34..32d3d021fd 100644
--- a/pom.xml
+++ b/pom.xml
@@ -43,6 +43,7 @@ limitations under the License.
         <module>flink-cdc-runtime</module>
         <module>flink-cdc-e2e-tests</module>
         <module>flink-cdc-pipeline-udf-examples</module>
+        <module>flink-cdc-python</module>
         <module>flink-cdc-pipeline-model</module>
     </modules>
 

Reply via email to