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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 16a2e68ae6 [core][python] Record writer build version in snapshots 
(#9283)
16a2e68ae6 is described below

commit 16a2e68ae6414cc6a2e139d873058ee8d6fcdc90
Author: YeJunHao <[email protected]>
AuthorDate: Thu Aug 20 10:08:46 2026 +0800

    [core][python] Record writer build version in snapshots (#9283)
---
 .../src/main/java/org/apache/paimon/Snapshot.java  |  20 +++
 paimon-core/pom.xml                                |  42 +++++++
 .../src/main/java/org/apache/paimon/Changelog.java |   3 +
 .../apache/paimon/operation/CoreFullVersion.java   |  57 +++++++++
 .../paimon/operation/FileStoreCommitImpl.java      |   4 +
 .../apache/paimon/table/system/SnapshotsTable.java |   6 +-
 .../src/main/java/org/apache/paimon/tag/Tag.java   |   4 +
 .../test/java/org/apache/paimon/SnapshotTest.java  |  27 ++++
 .../DataEvolutionRowIdReassignerTest.java          |   1 +
 .../paimon/catalog/RenamingSnapshotCommitTest.java |   1 +
 .../paimon/operation/CoreFullVersionTest.java      |  49 ++++++++
 .../paimon/operation/ExpireSnapshotsTest.java      |   1 +
 .../paimon/operation/FileStoreCommitTest.java      |  14 +++
 .../operation/commit/ConflictDetectionTest.java    |   1 +
 .../source/snapshot/WatermarkTimeTravelTest.java   |   1 +
 .../paimon/table/system/SnapshotsTableTest.java    |   3 +-
 .../org/apache/paimon/tag/TagAutoManagerTest.java  |   2 +
 .../test/java/org/apache/paimon/tag/TagTest.java   |   4 +
 .../apache/paimon/utils/SnapshotManagerTest.java   |   5 +
 paimon-python/dev/check-licensing.sh               |   1 +
 paimon-python/pypaimon/build_info.py               |  86 +++++++++++++
 paimon-python/pypaimon/changelog/changelog.py      |   1 +
 paimon-python/pypaimon/snapshot/snapshot.py        |   1 +
 .../pypaimon/table/system/snapshots_table.py       |   5 +
 paimon-python/pypaimon/tag/tag.py                  |   2 +
 paimon-python/pypaimon/tests/build_info_test.py    | 137 +++++++++++++++++++++
 .../pypaimon/tests/changelog_manager_test.py       |  29 +++--
 .../pypaimon/tests/system/snapshots_table_test.py  |   5 +-
 paimon-python/pypaimon/tests/tag_ttl_serde_test.py |   1 +
 .../pypaimon/tests/write/table_write_test.py       |   6 +-
 paimon-python/pypaimon/write/file_store_commit.py  |   2 +
 paimon-python/setup.py                             |  87 ++++++++++++-
 .../read/SparkDataEvolutionVectorReadTest.java     |   1 +
 33 files changed, 593 insertions(+), 16 deletions(-)

diff --git a/paimon-api/src/main/java/org/apache/paimon/Snapshot.java 
b/paimon-api/src/main/java/org/apache/paimon/Snapshot.java
index 2f98783ecc..d9de932506 100644
--- a/paimon-api/src/main/java/org/apache/paimon/Snapshot.java
+++ b/paimon-api/src/main/java/org/apache/paimon/Snapshot.java
@@ -61,6 +61,7 @@ public class Snapshot implements Serializable {
     protected static final String FIELD_CHANGELOG_MANIFEST_LIST_SIZE = 
"changelogManifestListSize";
     protected static final String FIELD_INDEX_MANIFEST = "indexManifest";
     protected static final String FIELD_COMMIT_USER = "commitUser";
+    protected static final String FIELD_WRITER_VERSION = "writerVersion";
     protected static final String FIELD_COMMIT_IDENTIFIER = "commitIdentifier";
     protected static final String FIELD_COMMIT_KIND = "commitKind";
     protected static final String FIELD_TIME_MILLIS = "timeMillis";
@@ -132,6 +133,13 @@ public class Snapshot implements Serializable {
     @JsonProperty(FIELD_COMMIT_USER)
     protected final String commitUser;
 
+    // Version of the Paimon writer which created this snapshot.
+    // Null for snapshots created before writer version was introduced.
+    @JsonProperty(FIELD_WRITER_VERSION)
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    @Nullable
+    protected final String writerVersion;
+
     // Mainly for snapshot deduplication.
     //
     // If multiple snapshots have the same commitIdentifier, reading from any 
of these snapshots
@@ -206,6 +214,7 @@ public class Snapshot implements Serializable {
             @Nullable Long changelogManifestListSize,
             @Nullable String indexManifest,
             String commitUser,
+            @Nullable String writerVersion,
             long commitIdentifier,
             CommitKind commitKind,
             long timeMillis,
@@ -230,6 +239,7 @@ public class Snapshot implements Serializable {
                 changelogManifestListSize,
                 indexManifest,
                 commitUser,
+                writerVersion,
                 commitIdentifier,
                 commitKind,
                 timeMillis,
@@ -258,6 +268,7 @@ public class Snapshot implements Serializable {
                     Long changelogManifestListSize,
             @JsonProperty(FIELD_INDEX_MANIFEST) @Nullable String indexManifest,
             @JsonProperty(FIELD_COMMIT_USER) String commitUser,
+            @JsonProperty(FIELD_WRITER_VERSION) @Nullable String writerVersion,
             @JsonProperty(FIELD_COMMIT_IDENTIFIER) long commitIdentifier,
             @JsonProperty(FIELD_COMMIT_KIND) CommitKind commitKind,
             @JsonProperty(FIELD_TIME_MILLIS) long timeMillis,
@@ -281,6 +292,7 @@ public class Snapshot implements Serializable {
         this.changelogManifestListSize = changelogManifestListSize;
         this.indexManifest = indexManifest;
         this.commitUser = commitUser;
+        this.writerVersion = writerVersion;
         this.commitIdentifier = commitIdentifier;
         this.commitKind = commitKind;
         this.timeMillis = timeMillis;
@@ -360,6 +372,12 @@ public class Snapshot implements Serializable {
         return commitUser;
     }
 
+    @JsonGetter(FIELD_WRITER_VERSION)
+    @Nullable
+    public String writerVersion() {
+        return writerVersion;
+    }
+
     @JsonGetter(FIELD_COMMIT_IDENTIFIER)
     public long commitIdentifier() {
         return commitIdentifier;
@@ -440,6 +458,7 @@ public class Snapshot implements Serializable {
                 changelogManifestListSize,
                 indexManifest,
                 commitUser,
+                writerVersion,
                 commitIdentifier,
                 commitKind,
                 timeMillis,
@@ -474,6 +493,7 @@ public class Snapshot implements Serializable {
                 && Objects.equals(changelogManifestListSize, 
that.changelogManifestListSize)
                 && Objects.equals(indexManifest, that.indexManifest)
                 && Objects.equals(commitUser, that.commitUser)
+                && Objects.equals(writerVersion, that.writerVersion)
                 && commitIdentifier == that.commitIdentifier
                 && commitKind == that.commitKind
                 && timeMillis == that.timeMillis
diff --git a/paimon-core/pom.xml b/paimon-core/pom.xml
index adb4568229..010672bd32 100644
--- a/paimon-core/pom.xml
+++ b/paimon-core/pom.xml
@@ -275,6 +275,48 @@ under the License.
 
     <build>
         <plugins>
+            <plugin>
+                <artifactId>maven-antrun-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>generate-build-info</id>
+                        <phase>process-resources</phase>
+                        <goals>
+                            <goal>run</goal>
+                        </goals>
+                        <configuration>
+                            <target>
+                                <!-- Prevent Git from discovering an unrelated 
repository
+                                     containing a Paimon source archive. -->
+                                <property name="paimon.core.git.ceiling"
+                                          location="${project.basedir}/../.."/>
+
+                                <exec executable="git"
+                                      dir="${project.basedir}"
+                                      failifexecutionfails="false"
+                                      failonerror="false"
+                                      
outputproperty="paimon.core.git.commit.id"
+                                      resultproperty="paimon.core.git.result">
+                                    <env key="GIT_CEILING_DIRECTORIES"
+                                         value="${paimon.core.git.ceiling}"/>
+                                    <arg value="rev-parse"/>
+                                    <arg value="HEAD"/>
+                                </exec>
+                                <condition property="paimon.core.commit.id"
+                                           
value="${paimon.core.git.commit.id}">
+                                    <equals arg1="${paimon.core.git.result}" 
arg2="0"/>
+                                </condition>
+                                <!-- Git metadata is not available in source 
archives. -->
+                                <property name="paimon.core.commit.id" 
value="UNKNOWN"/>
+                                <mkdir 
dir="${project.build.outputDirectory}/META-INF"/>
+                                <echo 
file="${project.build.outputDirectory}/META-INF/paimon-core.full-version"
+                                      
message="java-${project.version}-${paimon.core.commit.id}"/>
+                            </target>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-jar-plugin</artifactId>
diff --git a/paimon-core/src/main/java/org/apache/paimon/Changelog.java 
b/paimon-core/src/main/java/org/apache/paimon/Changelog.java
index aaa1f7f4d5..4433c80619 100644
--- a/paimon-core/src/main/java/org/apache/paimon/Changelog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/Changelog.java
@@ -55,6 +55,7 @@ public class Changelog extends Snapshot {
                 snapshot.changelogManifestListSize(),
                 snapshot.indexManifest(),
                 snapshot.commitUser(),
+                snapshot.writerVersion(),
                 snapshot.commitIdentifier(),
                 snapshot.commitKind(),
                 snapshot.timeMillis(),
@@ -83,6 +84,7 @@ public class Changelog extends Snapshot {
                     Long changelogManifestListSize,
             @JsonProperty(FIELD_INDEX_MANIFEST) @Nullable String indexManifest,
             @JsonProperty(FIELD_COMMIT_USER) String commitUser,
+            @JsonProperty(FIELD_WRITER_VERSION) @Nullable String writerVersion,
             @JsonProperty(FIELD_COMMIT_IDENTIFIER) long commitIdentifier,
             @JsonProperty(FIELD_COMMIT_KIND) CommitKind commitKind,
             @JsonProperty(FIELD_TIME_MILLIS) long timeMillis,
@@ -107,6 +109,7 @@ public class Changelog extends Snapshot {
                 changelogManifestListSize,
                 indexManifest,
                 commitUser,
+                writerVersion,
                 commitIdentifier,
                 commitKind,
                 timeMillis,
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/CoreFullVersion.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/CoreFullVersion.java
new file mode 100644
index 0000000000..314e75c85e
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/CoreFullVersion.java
@@ -0,0 +1,57 @@
+/*
+ * 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.paimon.operation;
+
+import javax.annotation.Nullable;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+
+/** Loads the full version of the current Paimon Core build. */
+final class CoreFullVersion {
+
+    @Nullable private static final String FULL_VERSION = load();
+
+    private CoreFullVersion() {}
+
+    @Nullable
+    static String get() {
+        return FULL_VERSION;
+    }
+
+    @Nullable
+    private static String load() {
+        InputStream inputStream =
+                
CoreFullVersion.class.getResourceAsStream("/META-INF/paimon-core.full-version");
+        if (inputStream == null) {
+            return null;
+        }
+
+        try (BufferedReader reader =
+                new BufferedReader(new InputStreamReader(inputStream, 
StandardCharsets.UTF_8))) {
+            String fullVersion = reader.readLine();
+            return fullVersion == null || fullVersion.trim().isEmpty() ? null 
: fullVersion.trim();
+        } catch (IOException e) {
+            return null;
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
index 505faba772..02ce5dc05d 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
@@ -1229,6 +1229,7 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                             changelogManifestList == null ? null : 
changelogManifestList.getRight(),
                             indexManifest,
                             commitUser,
+                            CoreFullVersion.get(),
                             identifier,
                             commitKind,
                             System.currentTimeMillis(),
@@ -1384,6 +1385,7 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                         null,
                         indexManifest,
                         commitUser,
+                        CoreFullVersion.get(),
                         Long.MAX_VALUE,
                         CommitKind.OVERWRITE,
                         System.currentTimeMillis(),
@@ -1470,6 +1472,7 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                         null,
                         targetSnapshot.indexManifest(),
                         commitUser,
+                        CoreFullVersion.get(),
                         Long.MAX_VALUE,
                         CommitKind.OVERWRITE,
                         System.currentTimeMillis(),
@@ -1618,6 +1621,7 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                         null,
                         latestSnapshot.indexManifest(),
                         commitUser,
+                        CoreFullVersion.get(),
                         Long.MAX_VALUE,
                         CommitKind.COMPACT,
                         System.currentTimeMillis(),
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/system/SnapshotsTable.java 
b/paimon-core/src/main/java/org/apache/paimon/table/system/SnapshotsTable.java
index 201192fde6..75d46b708e 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/system/SnapshotsTable.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/system/SnapshotsTable.java
@@ -109,8 +109,9 @@ public class SnapshotsTable implements ReadonlyTable {
                             new DataField(11, "changelog_record_count", new 
BigIntType(true)),
                             new DataField(12, "watermark", new 
BigIntType(true)),
                             new DataField(13, "next_row_id", new 
BigIntType(true)),
+                            new DataField(14, "operation", 
SerializationUtils.newStringType(true)),
                             new DataField(
-                                    14, "operation", 
SerializationUtils.newStringType(true))));
+                                    15, "writer_version", 
SerializationUtils.newStringType(true))));
 
     private final FileIO fileIO;
     private final Path location;
@@ -344,7 +345,8 @@ public class SnapshotsTable implements ReadonlyTable {
                     snapshot.nextRowId(),
                     snapshot.operation() == null
                             ? null
-                            : 
BinaryString.fromString(snapshot.operation().toString()));
+                            : 
BinaryString.fromString(snapshot.operation().toString()),
+                    BinaryString.fromString(snapshot.writerVersion()));
         }
     }
 }
diff --git a/paimon-core/src/main/java/org/apache/paimon/tag/Tag.java 
b/paimon-core/src/main/java/org/apache/paimon/tag/Tag.java
index 1acd425cd4..ac435782d0 100644
--- a/paimon-core/src/main/java/org/apache/paimon/tag/Tag.java
+++ b/paimon-core/src/main/java/org/apache/paimon/tag/Tag.java
@@ -71,6 +71,7 @@ public class Tag extends Snapshot {
                     Long changelogManifestListSize,
             @JsonProperty(FIELD_INDEX_MANIFEST) @Nullable String indexManifest,
             @JsonProperty(FIELD_COMMIT_USER) String commitUser,
+            @JsonProperty(FIELD_WRITER_VERSION) @Nullable String writerVersion,
             @JsonProperty(FIELD_COMMIT_IDENTIFIER) long commitIdentifier,
             @JsonProperty(FIELD_COMMIT_KIND) CommitKind commitKind,
             @JsonProperty(FIELD_TIME_MILLIS) long timeMillis,
@@ -97,6 +98,7 @@ public class Tag extends Snapshot {
                 changelogManifestListSize,
                 indexManifest,
                 commitUser,
+                writerVersion,
                 commitIdentifier,
                 commitKind,
                 timeMillis,
@@ -137,6 +139,7 @@ public class Tag extends Snapshot {
                 snapshot.changelogManifestListSize(),
                 snapshot.indexManifest(),
                 snapshot.commitUser(),
+                snapshot.writerVersion(),
                 snapshot.commitIdentifier(),
                 snapshot.commitKind(),
                 snapshot.timeMillis(),
@@ -166,6 +169,7 @@ public class Tag extends Snapshot {
                 changelogManifestListSize,
                 indexManifest,
                 commitUser,
+                writerVersion,
                 commitIdentifier,
                 commitKind,
                 timeMillis,
diff --git a/paimon-core/src/test/java/org/apache/paimon/SnapshotTest.java 
b/paimon-core/src/test/java/org/apache/paimon/SnapshotTest.java
index d67e9feb03..60e9efe983 100644
--- a/paimon-core/src/test/java/org/apache/paimon/SnapshotTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/SnapshotTest.java
@@ -68,6 +68,33 @@ public class SnapshotTest {
                                 + "  \"unknownKey\" : 22222\n"
                                 + "}");
         assertThat(snapshot.uuid()).isNull();
+        assertThat(snapshot.writerVersion()).isNull();
+    }
+
+    @Test
+    public void testSnapshotWriterVersion() {
+        String json =
+                "{\n"
+                        + "  \"version\" : 3,\n"
+                        + "  \"id\" : 1,\n"
+                        + "  \"schemaId\" : 0,\n"
+                        + "  \"baseManifestList\" : \"m-0\",\n"
+                        + "  \"deltaManifestList\" : \"m-1\",\n"
+                        + "  \"commitUser\" : \"user\",\n"
+                        + "  \"writerVersion\" : 
\"java-2.1-SNAPSHOT-0123456789012345678901234567890123456789\",\n"
+                        + "  \"commitIdentifier\" : 0,\n"
+                        + "  \"commitKind\" : \"APPEND\",\n"
+                        + "  \"timeMillis\" : 1000,\n"
+                        + "  \"totalRecordCount\" : 10,\n"
+                        + "  \"deltaRecordCount\" : 5\n"
+                        + "}";
+        Snapshot snapshot = Snapshot.fromJson(json);
+        assertThat(snapshot.writerVersion())
+                
.isEqualTo("java-2.1-SNAPSHOT-0123456789012345678901234567890123456789");
+        assertThat(Snapshot.fromJson(snapshot.toJson())).isEqualTo(snapshot);
+        Changelog changelog = new Changelog(snapshot);
+        
assertThat(changelog.writerVersion()).isEqualTo(snapshot.writerVersion());
+        
assertThat(Changelog.fromJson(changelog.toJson())).isEqualTo(changelog);
     }
 
     @Test
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
index 0de855be14..08b35b7bdf 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
@@ -2896,6 +2896,7 @@ public class DataEvolutionRowIdReassignerTest extends 
TableTestBase {
                         latest.changelogManifestListSize(),
                         indexManifest,
                         latest.commitUser(),
+                        latest.writerVersion(),
                         latest.commitIdentifier(),
                         latest.commitKind(),
                         latest.timeMillis(),
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/catalog/RenamingSnapshotCommitTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/catalog/RenamingSnapshotCommitTest.java
index 1eef5c53cf..4e7fa27e7b 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/catalog/RenamingSnapshotCommitTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/catalog/RenamingSnapshotCommitTest.java
@@ -128,6 +128,7 @@ public class RenamingSnapshotCommitTest {
                 changelogManifestListSize,
                 indexManifest,
                 commitUser,
+                null,
                 commitIdentifier,
                 commitKind,
                 timeMillis,
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/CoreFullVersionTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/CoreFullVersionTest.java
new file mode 100644
index 0000000000..0ed7f2e5b4
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/CoreFullVersionTest.java
@@ -0,0 +1,49 @@
+/*
+ * 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.paimon.operation;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link CoreFullVersion}. */
+public class CoreFullVersionTest {
+
+    @Test
+    public void testFullVersion() throws Exception {
+        String fullVersion = 
readResource("/META-INF/paimon-core.full-version");
+
+        assertThat(CoreFullVersion.get()).isEqualTo(fullVersion);
+        assertThat(fullVersion).matches("java-.+-(UNKNOWN|[0-9a-f]{40})");
+    }
+
+    private String readResource(String path) throws Exception {
+        InputStream inputStream = 
CoreFullVersion.class.getResourceAsStream(path);
+        assertThat(inputStream).isNotNull();
+        try (BufferedReader reader =
+                new BufferedReader(new InputStreamReader(inputStream, 
StandardCharsets.UTF_8))) {
+            return reader.readLine().trim();
+        }
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
index c4de0f44e1..143f8445b8 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/ExpireSnapshotsTest.java
@@ -411,6 +411,7 @@ public class ExpireSnapshotsTest {
                 null,
                 null,
                 "test",
+                null,
                 0L,
                 Snapshot.CommitKind.APPEND,
                 0L,
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
index 3297383dcb..c19f318073 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
@@ -1434,6 +1434,19 @@ public class FileStoreCommitTest {
         }
     }
 
+    @Test
+    public void testSnapshotWriterVersion() throws Exception {
+        TestFileStore store = createStore(false);
+
+        try (FileStoreCommit fileStoreCommit = store.newCommit()) {
+            fileStoreCommit.ignoreEmptyCommit(false);
+            fileStoreCommit.commit(new ManifestCommittable(0), false);
+        }
+
+        
assertThat(checkNotNull(store.snapshotManager().latestSnapshot()).writerVersion())
+                .isEqualTo(CoreFullVersion.get());
+    }
+
     @Test
     public void testGlobalIndexCommitChecksExistingRowIds() throws Exception {
         TestFileStore store = createRowTrackingDataEvolutionStore();
@@ -2192,6 +2205,7 @@ public class FileStoreCommitTest {
                             null,
                             previousSnapshot == null ? null : 
previousSnapshot.indexManifest(),
                             "conflict-user",
+                            snapshot.writerVersion(),
                             Long.MAX_VALUE,
                             Snapshot.CommitKind.ANALYZE,
                             System.currentTimeMillis(),
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
index de0a04bb3d..1fa8430b66 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/commit/ConflictDetectionTest.java
@@ -1540,6 +1540,7 @@ class ConflictDetectionTest {
                 null,
                 null,
                 "commit-user",
+                null,
                 id,
                 Snapshot.CommitKind.APPEND,
                 id,
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/snapshot/WatermarkTimeTravelTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/source/snapshot/WatermarkTimeTravelTest.java
index 569e2d5bd4..172069ede6 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/source/snapshot/WatermarkTimeTravelTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/snapshot/WatermarkTimeTravelTest.java
@@ -262,6 +262,7 @@ public class WatermarkTimeTravelTest extends 
ScannerTestBase {
                         s.changelogManifestListSize(),
                         s.indexManifest(),
                         s.commitUser(),
+                        s.writerVersion(),
                         s.commitIdentifier(),
                         s.commitKind(),
                         s.timeMillis(),
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/system/SnapshotsTableTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/system/SnapshotsTableTest.java
index 34d634e642..560fa72de4 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/system/SnapshotsTableTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/system/SnapshotsTableTest.java
@@ -161,7 +161,8 @@ public class SnapshotsTableTest extends TableTestBase {
                             snapshot.nextRowId(),
                             snapshot.operation() == null
                                     ? null
-                                    : 
BinaryString.fromString(snapshot.operation().toString())));
+                                    : 
BinaryString.fromString(snapshot.operation().toString()),
+                            
BinaryString.fromString(snapshot.writerVersion())));
         }
 
         return expectedRow;
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/tag/TagAutoManagerTest.java 
b/paimon-core/src/test/java/org/apache/paimon/tag/TagAutoManagerTest.java
index d262110f03..d7951fd584 100644
--- a/paimon-core/src/test/java/org/apache/paimon/tag/TagAutoManagerTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/tag/TagAutoManagerTest.java
@@ -401,6 +401,7 @@ public class TagAutoManagerTest extends 
PrimaryKeyTableTestBase {
                         null,
                         null,
                         null,
+                        null,
                         0L,
                         Snapshot.CommitKind.APPEND,
                         1000,
@@ -431,6 +432,7 @@ public class TagAutoManagerTest extends 
PrimaryKeyTableTestBase {
                         null,
                         null,
                         null,
+                        null,
                         0L,
                         Snapshot.CommitKind.APPEND,
                         1000,
diff --git a/paimon-core/src/test/java/org/apache/paimon/tag/TagTest.java 
b/paimon-core/src/test/java/org/apache/paimon/tag/TagTest.java
index 459e006f17..4a9a0c6eb5 100644
--- a/paimon-core/src/test/java/org/apache/paimon/tag/TagTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/tag/TagTest.java
@@ -44,6 +44,7 @@ public class TagTest {
                     null,
                     null,
                     null,
+                    "java-2.1-SNAPSHOT-commit-id",
                     0L,
                     Snapshot.CommitKind.APPEND,
                     1000,
@@ -68,6 +69,7 @@ public class TagTest {
                         + "  \"baseManifestList\" : null,\n"
                         + "  \"deltaManifestList\" : null,\n"
                         + "  \"commitUser\" : null,\n"
+                        + "  \"writerVersion\" : 
\"java-2.1-SNAPSHOT-commit-id\",\n"
                         + "  \"commitIdentifier\" : 0,\n"
                         + "  \"commitKind\" : \"APPEND\",\n"
                         + "  \"timeMillis\" : 1000,\n"
@@ -94,6 +96,7 @@ public class TagTest {
                         + "  \"baseManifestList\" : null,\n"
                         + "  \"deltaManifestList\" : null,\n"
                         + "  \"commitUser\" : null,\n"
+                        + "  \"writerVersion\" : 
\"java-2.1-SNAPSHOT-commit-id\",\n"
                         + "  \"commitIdentifier\" : 0,\n"
                         + "  \"commitKind\" : \"APPEND\",\n"
                         + "  \"timeMillis\" : 1000,\n"
@@ -106,5 +109,6 @@ public class TagTest {
 
         Tag newTag = Tag.fromJson(tagJson);
         assertEquals(tag, newTag);
+        assertEquals("java-2.1-SNAPSHOT-commit-id", 
newTag.trimToSnapshot().writerVersion());
     }
 }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/utils/SnapshotManagerTest.java 
b/paimon-core/src/test/java/org/apache/paimon/utils/SnapshotManagerTest.java
index 2750feb902..5adbf4129f 100644
--- a/paimon-core/src/test/java/org/apache/paimon/utils/SnapshotManagerTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/utils/SnapshotManagerTest.java
@@ -428,6 +428,7 @@ public class SnapshotManagerTest {
                 null,
                 null,
                 null,
+                null,
                 0L,
                 Snapshot.CommitKind.APPEND,
                 millis,
@@ -453,6 +454,7 @@ public class SnapshotManagerTest {
                 null,
                 null,
                 null,
+                null,
                 0L,
                 Snapshot.CommitKind.APPEND,
                 millis,
@@ -487,6 +489,7 @@ public class SnapshotManagerTest {
                         null,
                         null,
                         null,
+                        null,
                         0L,
                         Snapshot.CommitKind.APPEND,
                         millis,
@@ -519,6 +522,7 @@ public class SnapshotManagerTest {
                             null,
                             null,
                             "lastCommitUser",
+                            null,
                             0L,
                             Snapshot.CommitKind.APPEND,
                             i * 1000,
@@ -573,6 +577,7 @@ public class SnapshotManagerTest {
                             null,
                             null,
                             null,
+                            null,
                             0L,
                             Snapshot.CommitKind.APPEND,
                             i * 1000,
diff --git a/paimon-python/dev/check-licensing.sh 
b/paimon-python/dev/check-licensing.sh
index ec1fe9df0e..c867a0d7b6 100755
--- a/paimon-python/dev/check-licensing.sh
+++ b/paimon-python/dev/check-licensing.sh
@@ -79,6 +79,7 @@ else
         -e '.*entry_points[.]txt$'
         -e '.*requires[.]txt$'
         -e '.*top_level[.]txt$'
+        -e '.*_full_version$'
         -e '.*[.]jsonl$'
         -d "${PACKAGE_DIR}"
     )
diff --git a/paimon-python/pypaimon/build_info.py 
b/paimon-python/pypaimon/build_info.py
new file mode 100644
index 0000000000..e034f356fd
--- /dev/null
+++ b/paimon-python/pypaimon/build_info.py
@@ -0,0 +1,86 @@
+# 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.
+
+import os
+import re
+import subprocess
+
+_UNKNOWN = "UNKNOWN"
+_FULL_VERSION_FILE = os.path.join(os.path.dirname(__file__), "_full_version")
+_SETUP_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), 
"setup.py")
+
+
+def _repository_root():
+    python_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+    parent = os.path.dirname(python_root)
+    if os.path.basename(python_root) == "paimon-python" and os.path.exists(
+            os.path.join(parent, "pom.xml")):
+        return parent
+    return python_root
+
+
+def git_commit_id():
+    """Return the current Paimon Git revision without discovering an outer 
repo."""
+    repository_root = _repository_root()
+    env = os.environ.copy()
+    env["GIT_CEILING_DIRECTORIES"] = os.path.dirname(repository_root)
+    try:
+        return subprocess.check_output(
+            ["git", "-C", repository_root, "rev-parse", "HEAD"],
+            stderr=subprocess.DEVNULL,
+            env=env,
+        ).decode("utf-8").strip()
+    except Exception:
+        return _UNKNOWN
+
+
+def _source_version():
+    try:
+        with open(_SETUP_FILE, "r") as setup_file:
+            match = re.search(
+                r'^VERSION = ["\']([^"\']+)["\']$',
+                setup_file.read(),
+                re.MULTILINE,
+            )
+            return None if match is None else match.group(1)
+    except OSError:
+        return None
+
+
+def _load_full_version():
+    """Return the embedded full version, or derive it from the checkout."""
+    try:
+        with open(_FULL_VERSION_FILE, "r") as full_version_file:
+            value = full_version_file.read().strip()
+            if value:
+                return value
+    except OSError:
+        pass
+    version = _source_version()
+    return (
+        _UNKNOWN
+        if version is None
+        else "python-{}-{}".format(version, git_commit_id())
+    )
+
+
+_FULL_VERSION = _load_full_version()
+
+
+def full_version():
+    """Return ``<pypaimon-version>-<commit-id>`` for snapshot provenance."""
+    return _FULL_VERSION
diff --git a/paimon-python/pypaimon/changelog/changelog.py 
b/paimon-python/pypaimon/changelog/changelog.py
index f562395390..195d530638 100644
--- a/paimon-python/pypaimon/changelog/changelog.py
+++ b/paimon-python/pypaimon/changelog/changelog.py
@@ -47,6 +47,7 @@ class Changelog(Snapshot):
             changelog_manifest_list_size=snapshot.changelog_manifest_list_size,
             index_manifest=snapshot.index_manifest,
             commit_user=snapshot.commit_user,
+            writer_version=snapshot.writer_version,
             commit_identifier=snapshot.commit_identifier,
             commit_kind=snapshot.commit_kind,
             time_millis=snapshot.time_millis,
diff --git a/paimon-python/pypaimon/snapshot/snapshot.py 
b/paimon-python/pypaimon/snapshot/snapshot.py
index 407f27f0d7..d10e80a029 100644
--- a/paimon-python/pypaimon/snapshot/snapshot.py
+++ b/paimon-python/pypaimon/snapshot/snapshot.py
@@ -57,3 +57,4 @@ class Snapshot:
             "json_missing_default": None,
         },
     )
+    writer_version: Optional[str] = optional_json_field("writerVersion", 
"non_null")
diff --git a/paimon-python/pypaimon/table/system/snapshots_table.py 
b/paimon-python/pypaimon/table/system/snapshots_table.py
index be7331d21f..d32c36163c 100644
--- a/paimon-python/pypaimon/table/system/snapshots_table.py
+++ b/paimon-python/pypaimon/table/system/snapshots_table.py
@@ -40,6 +40,8 @@ TABLE_TYPE = RowType(False, [
     DataField(11, "changelog_record_count", AtomicType("BIGINT", 
nullable=True)),
     DataField(12, "watermark", AtomicType("BIGINT", nullable=True)),
     DataField(13, "next_row_id", AtomicType("BIGINT", nullable=True)),
+    # Keep the field ID aligned with Java; ID 14 is reserved for operation.
+    DataField(15, "writer_version", AtomicType("STRING", nullable=True)),
 ])
 
 
@@ -75,6 +77,7 @@ class SnapshotsTable(SystemTable):
         changelog_record_counts: List[Optional[int]] = []
         watermarks: List[Optional[int]] = []
         next_row_ids: List[Optional[int]] = []
+        writer_versions: List[Optional[str]] = []
 
         for snap in snapshots:
             snapshot_ids.append(int(snap.id))
@@ -99,6 +102,7 @@ class SnapshotsTable(SystemTable):
                 None if snap.watermark is None else int(snap.watermark))
             next_row_ids.append(
                 None if snap.next_row_id is None else int(snap.next_row_id))
+            writer_versions.append(snap.writer_version)
 
         return pyarrow.table({
             "snapshot_id": pyarrow.array(snapshot_ids, type=pyarrow.int64()),
@@ -122,4 +126,5 @@ class SnapshotsTable(SystemTable):
                 changelog_record_counts, type=pyarrow.int64()),
             "watermark": pyarrow.array(watermarks, type=pyarrow.int64()),
             "next_row_id": pyarrow.array(next_row_ids, type=pyarrow.int64()),
+            "writer_version": pyarrow.array(writer_versions, 
type=pyarrow.string()),
         })
diff --git a/paimon-python/pypaimon/tag/tag.py 
b/paimon-python/pypaimon/tag/tag.py
index 80b4d5c3f1..23df204428 100644
--- a/paimon-python/pypaimon/tag/tag.py
+++ b/paimon-python/pypaimon/tag/tag.py
@@ -70,6 +70,7 @@ class Tag(Snapshot):
             total_record_count=snapshot.total_record_count,
             delta_record_count=snapshot.delta_record_count,
             commit_user=snapshot.commit_user,
+            writer_version=snapshot.writer_version,
             commit_identifier=snapshot.commit_identifier,
             commit_kind=snapshot.commit_kind,
             time_millis=snapshot.time_millis,
@@ -99,6 +100,7 @@ class Tag(Snapshot):
             total_record_count=self.total_record_count,
             delta_record_count=self.delta_record_count,
             commit_user=self.commit_user,
+            writer_version=self.writer_version,
             commit_identifier=self.commit_identifier,
             commit_kind=self.commit_kind,
             time_millis=self.time_millis,
diff --git a/paimon-python/pypaimon/tests/build_info_test.py 
b/paimon-python/pypaimon/tests/build_info_test.py
new file mode 100644
index 0000000000..c1d7d1e385
--- /dev/null
+++ b/paimon-python/pypaimon/tests/build_info_test.py
@@ -0,0 +1,137 @@
+# 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.
+
+import os
+import shutil
+import subprocess
+import sys
+import tarfile
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from pypaimon import build_info
+
+
+class BuildInfoTest(unittest.TestCase):
+
+    def test_full_version(self):
+        self.assertRegex(
+            build_info.full_version(),
+            r"^python-2\.1\.dev-(UNKNOWN|[0-9a-f]{40})$",
+        )
+
+    def test_embedded_full_version(self):
+        with tempfile.NamedTemporaryFile(mode="w", delete=False) as 
version_file:
+            version_file.write(
+                "python-2.1.dev-0123456789012345678901234567890123456789\n")
+            path = version_file.name
+        try:
+            with patch.object(build_info, "_FULL_VERSION_FILE", path):
+                self.assertEqual(
+                    "python-2.1.dev-0123456789012345678901234567890123456789",
+                    build_info._load_full_version(),
+                )
+        finally:
+            os.remove(path)
+
+    @unittest.skipIf(shutil.which("git") is None, "Git is not available")
+    def test_does_not_discover_outer_repository(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            outer = os.path.join(tmp, "outer")
+            source = os.path.join(outer, "source")
+            os.makedirs(source)
+            subprocess.check_call(
+                ["git", "init", "-q", outer], stdout=subprocess.DEVNULL)
+            subprocess.check_call(
+                ["git", "-C", outer, "config", "user.name", "test"])
+            subprocess.check_call(
+                ["git", "-C", outer, "config", "user.email", 
"[email protected]"])
+            subprocess.check_call(
+                ["git", "-C", outer, "commit", "-q", "--allow-empty", "-m", 
"outer"])
+
+            fake_module = os.path.join(source, "pypaimon", "build_info.py")
+            with patch.object(build_info, "__file__", fake_module):
+                self.assertEqual("UNKNOWN", build_info.git_commit_id())
+
+    @unittest.skipIf(shutil.which("git") is None, "Git is not available")
+    def test_sdist_provenance_survives_downstream_git_repository(self):
+        project_root = os.path.dirname(os.path.dirname(os.path.dirname(
+            os.path.abspath(__file__))))
+        with tempfile.TemporaryDirectory() as tmp:
+            source = os.path.join(tmp, "paimon-python")
+            shutil.copytree(
+                project_root,
+                source,
+                ignore=shutil.ignore_patterns(
+                    ".pytest_cache", "*.egg-info", "__pycache__", "build", 
"dist"),
+            )
+            upstream_commit = self._init_git_repository(source, "upstream")
+
+            sdist_dir = os.path.join(tmp, "sdist")
+            os.makedirs(sdist_dir)
+            subprocess.check_call(
+                [sys.executable, "setup.py", "-q", "sdist", "--dist-dir", 
sdist_dir],
+                cwd=source,
+            )
+            archives = [
+                os.path.join(sdist_dir, name)
+                for name in os.listdir(sdist_dir)
+                if name.endswith(".tar.gz")
+            ]
+            self.assertEqual(1, len(archives))
+
+            extracted_root = os.path.join(tmp, "extracted")
+            os.makedirs(extracted_root)
+            with tarfile.open(archives[0], "r:gz") as archive:
+                top_level = archive.getnames()[0].split("/")[0]
+                archive.extractall(extracted_root)
+            extracted = os.path.join(extracted_root, top_level)
+            embedded_file = os.path.join(extracted, "pypaimon", 
"_full_version")
+            with open(embedded_file, "r") as full_version_file:
+                embedded = full_version_file.read().strip()
+            self.assertEqual("python-2.1.dev-" + upstream_commit, embedded)
+
+            downstream_commit = self._init_git_repository(extracted, 
"downstream")
+            self.assertNotEqual(upstream_commit, downstream_commit)
+            build_lib = os.path.join(tmp, "build")
+            subprocess.check_call(
+                [sys.executable, "setup.py", "-q", "build_py", "--build-lib", 
build_lib],
+                cwd=extracted,
+            )
+            with open(
+                    os.path.join(build_lib, "pypaimon", "_full_version"),
+                    "r",
+            ) as full_version_file:
+                self.assertEqual(embedded, full_version_file.read().strip())
+
+    @staticmethod
+    def _init_git_repository(path, message):
+        subprocess.check_call(["git", "init", "-q", path])
+        subprocess.check_call(["git", "-C", path, "config", "user.name", 
"test"])
+        subprocess.check_call(
+            ["git", "-C", path, "config", "user.email", "[email protected]"])
+        subprocess.check_call(["git", "-C", path, "add", "."])
+        subprocess.check_call(
+            ["git", "-C", path, "commit", "-q", "-m", message])
+        return subprocess.check_output(
+            ["git", "-C", path, "rev-parse", "HEAD"]
+        ).decode("utf-8").strip()
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/paimon-python/pypaimon/tests/changelog_manager_test.py 
b/paimon-python/pypaimon/tests/changelog_manager_test.py
index 3f726db7a4..b4e2daaedb 100644
--- a/paimon-python/pypaimon/tests/changelog_manager_test.py
+++ b/paimon-python/pypaimon/tests/changelog_manager_test.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import dataclasses
 import os
 import shutil
 import tempfile
@@ -22,6 +23,7 @@ import unittest
 
 from pypaimon import CatalogFactory, Schema
 from pypaimon.changelog import Changelog, ChangelogManager
+from pypaimon.snapshot.snapshot import Snapshot
 
 import pyarrow as pa
 
@@ -119,15 +121,28 @@ class TestChangelogManager(unittest.TestCase):
 
     def test_changelog_from_snapshot(self):
         """Test that Changelog can be created from a Snapshot."""
+        snapshot = Snapshot(
+            version=3,
+            id=1,
+            schema_id=2,
+            base_manifest_list="base",
+            delta_manifest_list="delta",
+            total_record_count=10,
+            delta_record_count=5,
+            commit_user="user",
+            commit_identifier=3,
+            commit_kind="APPEND",
+            time_millis=1000,
+            writer_version="python-2.1.dev-commit-id",
+        )
 
-        snapshot_manager = self.table.snapshot_manager()
-        snapshot = snapshot_manager.get_latest_snapshot()
+        changelog = Changelog.from_snapshot(snapshot)
 
-        if snapshot:
-            changelog = Changelog.from_snapshot(snapshot)
-            self.assertEqual(changelog.id, snapshot.id)
-            self.assertEqual(changelog.schema_id, snapshot.schema_id)
-            self.assertEqual(changelog.time_millis, snapshot.time_millis)
+        for field in dataclasses.fields(Snapshot):
+            self.assertEqual(
+                getattr(snapshot, field.name), getattr(changelog, field.name),
+                "Changelog dropped/changed Snapshot field 
'{}'".format(field.name),
+            )
 
 
 if __name__ == '__main__':
diff --git a/paimon-python/pypaimon/tests/system/snapshots_table_test.py 
b/paimon-python/pypaimon/tests/system/snapshots_table_test.py
index 4e3e637c07..aadb69dcaa 100644
--- a/paimon-python/pypaimon/tests/system/snapshots_table_test.py
+++ b/paimon-python/pypaimon/tests/system/snapshots_table_test.py
@@ -56,6 +56,7 @@ def _seed_two_snapshots(table):
         commit_identifier=100,
         commit_kind="APPEND",
         time_millis=_T0,
+        writer_version="python-2.1.dev-commit-one",
         watermark=7,
     )
     snapshot_two = Snapshot(
@@ -115,7 +116,7 @@ class SnapshotsTableTest(unittest.TestCase):
             ("changelog_manifest_list", True),
             ("total_record_count", True), ("delta_record_count", True),
             ("changelog_record_count", True), ("watermark", True),
-            ("next_row_id", True),
+            ("next_row_id", True), ("writer_version", True),
         ]
         self.assertEqual([n for n, _ in expected],
                          [f.name for f in row_type.fields])
@@ -153,6 +154,8 @@ class SnapshotsTableTest(unittest.TestCase):
                          
arrow_table.column("changelog_record_count").to_pylist())
         self.assertEqual([None, None],
                          arrow_table.column("next_row_id").to_pylist())
+        self.assertEqual(["python-2.1.dev-commit-one", None],
+                         arrow_table.column("writer_version").to_pylist())
 
         times = arrow_table.column("commit_time").to_pylist()
         self.assertIsInstance(times[0], datetime.datetime)
diff --git a/paimon-python/pypaimon/tests/tag_ttl_serde_test.py 
b/paimon-python/pypaimon/tests/tag_ttl_serde_test.py
index 23af669768..4bcbd60576 100644
--- a/paimon-python/pypaimon/tests/tag_ttl_serde_test.py
+++ b/paimon-python/pypaimon/tests/tag_ttl_serde_test.py
@@ -72,6 +72,7 @@ def _full_snapshot():
         total_record_count=100,
         delta_record_count=10,
         commit_user="u",
+        writer_version="python-2.1.dev-commit-id",
         commit_identifier=42,
         commit_kind="APPEND",
         time_millis=1000,
diff --git a/paimon-python/pypaimon/tests/write/table_write_test.py 
b/paimon-python/pypaimon/tests/write/table_write_test.py
index e02a688cf9..4009b25790 100644
--- a/paimon-python/pypaimon/tests/write/table_write_test.py
+++ b/paimon-python/pypaimon/tests/write/table_write_test.py
@@ -28,6 +28,7 @@ from pypaimon import CatalogFactory, Schema
 import pyarrow as pa
 from parameterized import parameterized
 
+from pypaimon.build_info import full_version as build_full_version
 from pypaimon.common.json_util import JSON
 from pypaimon.common.options.core_options import CoreOptions
 from pypaimon.manifest.manifest_list_manager import ManifestListManager
@@ -426,8 +427,11 @@ class TableWriteTest(unittest.TestCase):
         self.assertEqual(self.expected, actual)
 
         # snapshot
-        snapshot_json: str = 
JSON.to_json(table.snapshot_manager().get_latest_snapshot())
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        snapshot_json: str = JSON.to_json(snapshot)
         self.assertEqual(True, snapshot_json.__contains__("baseManifestList"))
+        self.assertEqual(build_full_version(), snapshot.writer_version)
+        self.assertEqual(True, snapshot_json.__contains__("writerVersion"))
         self.assertEqual(False, snapshot_json.__contains__("nextRowId"))
 
     def test_write_row_append_only_partitioned_table(self):
diff --git a/paimon-python/pypaimon/write/file_store_commit.py 
b/paimon-python/pypaimon/write/file_store_commit.py
index adcfa79148..2f64c59365 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -21,6 +21,7 @@ import time
 import uuid
 from typing import Dict, List, Optional
 
+from pypaimon.build_info import full_version as build_full_version
 from pypaimon.common.options.core_options import CoreOptions
 from pypaimon.common.predicate_builder import PredicateBuilder
 from pypaimon.manifest.manifest_file_manager import ManifestFileManager
@@ -761,6 +762,7 @@ class FileStoreCommit:
                 total_record_count=total_record_count,
                 delta_record_count=delta_record_count,
                 commit_user=self.commit_user,
+                writer_version=build_full_version(),
                 commit_identifier=commit_identifier,
                 commit_kind=commit_kind,
                 time_millis=int(time.time() * 1000),
diff --git a/paimon-python/setup.py b/paimon-python/setup.py
index 27e5efe6e2..8907ef4d88 100644
--- a/paimon-python/setup.py
+++ b/paimon-python/setup.py
@@ -23,10 +23,75 @@ import sys
 import tarfile
 import tempfile
 from setuptools import find_packages, setup
+from setuptools.command.build_py import build_py
+from setuptools.command.sdist import sdist
+
+PYTHON_ROOT = os.path.dirname(os.path.abspath(__file__))
+FULL_VERSION_FILE = os.path.join(PYTHON_ROOT, "pypaimon", "_full_version")
+UNKNOWN_COMMIT_ID = "UNKNOWN"
 
 VERSION = "2.1.dev"
 
 
+def _repository_root():
+    parent = os.path.dirname(PYTHON_ROOT)
+    if os.path.basename(PYTHON_ROOT) == "paimon-python" and os.path.exists(
+            os.path.join(parent, "pom.xml")):
+        return parent
+    return PYTHON_ROOT
+
+
+def _git_output(args):
+    repository_root = _repository_root()
+    env = os.environ.copy()
+    env["GIT_CEILING_DIRECTORIES"] = os.path.dirname(repository_root)
+    try:
+        return subprocess.check_output(
+            ["git", "-C", repository_root] + args,
+            stderr=subprocess.DEVNULL,
+            env=env,
+        ).decode("utf-8").strip()
+    except Exception:
+        return None
+
+
+def _full_version():
+    try:
+        with open(FULL_VERSION_FILE, "r") as full_version_file:
+            embedded = full_version_file.read().strip()
+            if embedded:
+                return embedded
+    except OSError:
+        pass
+
+    git_commit_id = _git_output(["rev-parse", "HEAD"])
+    if git_commit_id is None:
+        git_commit_id = UNKNOWN_COMMIT_ID
+    return "python-{}-{}".format(VERSION, git_commit_id)
+
+
+def _write_full_version(root):
+    package_dir = os.path.join(root, "pypaimon")
+    if not os.path.exists(package_dir):
+        os.makedirs(package_dir)
+    with open(os.path.join(package_dir, "_full_version"), "w") as 
full_version_file:
+        full_version_file.write(_full_version() + "\n")
+
+
+class PaimonBuildPy(build_py):
+
+    def run(self):
+        build_py.run(self)
+        _write_full_version(self.build_lib)
+
+
+class PaimonSdist(sdist):
+
+    def make_release_tree(self, base_dir, files):
+        sdist.make_release_tree(self, base_dir, files)
+        _write_full_version(base_dir)
+
+
 def get_dev_version():
     """Generate dev version with commit date.
     Format: 2.1.devYYYYMMDD (e.g. 2.1.dev20260415)
@@ -37,10 +102,10 @@ def get_dev_version():
         return None
 
     try:
-        date_str = subprocess.check_output(
-            ["git", "log", "-1", "--format=%cd", "--date=format:%Y%m%d"],
-            stderr=subprocess.DEVNULL
-        ).decode("utf-8").strip()
+        date_str = _git_output(
+            ["log", "-1", "--format=%cd", "--date=format:%Y%m%d"])
+        if date_str is None:
+            raise RuntimeError("Git commit date is unavailable")
     except Exception:
         print("Warning: git not available, skipping dev package.")
         return None
@@ -104,6 +169,18 @@ def _build_dev_package():
             with open(setup_py, "w") as f:
                 f.write(content)
 
+        # Keep the embedded full version consistent with the dev package 
version.
+        full_version_file = os.path.join(dev_dir, "pypaimon", "_full_version")
+        if os.path.exists(full_version_file):
+            with open(full_version_file, "r") as f:
+                content = f.read()
+            version_prefix = "python-{}-".format(VERSION)
+            if content.startswith(version_prefix):
+                content = "python-{}-{}".format(
+                    dev_version, content[len(version_prefix):])
+            with open(full_version_file, "w") as f:
+                f.write(content)
+
         dev_tar = os.path.join("dist", dev_name + ".tar.gz")
         with tarfile.open(dev_tar, "w:gz") as tar:
             tar.add(dev_dir, arcname=dev_name)
@@ -147,6 +224,8 @@ setup(
     version=VERSION,
     packages=PACKAGES,
     include_package_data=True,
+    package_data={"pypaimon": ["_full_version"]},
+    cmdclass={"build_py": PaimonBuildPy, "sdist": PaimonSdist},
     install_requires=install_requires,
     entry_points={
         'console_scripts': [
diff --git 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorReadTest.java
 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorReadTest.java
index dd13cd365b..2bd1833705 100644
--- 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorReadTest.java
+++ 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/read/SparkDataEvolutionVectorReadTest.java
@@ -124,6 +124,7 @@ public class SparkDataEvolutionVectorReadTest {
                 null,
                 null,
                 "user",
+                null,
                 0L,
                 Snapshot.CommitKind.APPEND,
                 0L,

Reply via email to