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

jt2594838 pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/tsfile.git


The following commit(s) were added to refs/heads/develop by this push:
     new 9ec0abf57 Add C++ and Python TsFile properties support (#897)
9ec0abf57 is described below

commit 9ec0abf57835ec38873e9e7e4e84e70fd745eeb3
Author: Colin Lee <[email protected]>
AuthorDate: Thu Aug 6 12:31:50 2026 +0800

    Add C++ and Python TsFile properties support (#897)
    
    * Add C++ and Python TsFile properties support
    
    * Fix TsFile properties metadata compatibility
    
    * Fix Windows TsFile binary file mode
    
    * Preserve legacy empty BloomFilter metadata
---
 cpp/README-zh.md                                   |  19 +++
 cpp/README.md                                      |  20 +++
 cpp/src/common/tsfile_common.cc                    | 163 ++++++++++++++++---
 cpp/src/common/tsfile_common.h                     |  32 +++-
 cpp/src/cwrapper/tsfile_cwrapper.cc                | 148 +++++++++++++++++
 cpp/src/cwrapper/tsfile_cwrapper.h                 |  42 +++++
 cpp/src/file/tsfile_io_writer.cc                   |  57 +++++--
 cpp/src/file/tsfile_io_writer.h                    |   5 +
 cpp/src/file/write_file.cc                         |   6 +
 cpp/src/reader/bloom_filter.cc                     |  39 ++++-
 cpp/src/reader/tsfile_reader.cc                    |   9 ++
 cpp/src/reader/tsfile_reader.h                     |   3 +
 cpp/src/writer/tsfile_table_writer.cc              |  17 ++
 cpp/src/writer/tsfile_table_writer.h               |   6 +
 cpp/src/writer/tsfile_writer.cc                    |  17 ++
 cpp/src/writer/tsfile_writer.h                     |   6 +
 cpp/test/common/tsfile_common_test.cc              |  78 ++++++++-
 cpp/test/cwrapper/cwrapper_properties_test.cc      | 177 +++++++++++++++++++++
 cpp/test/reader/bloom_filter_test.cc               |  49 ++++++
 .../writer/table_view/tsfile_writer_table_test.cc  |  24 +++
 cpp/test/writer/tsfile_properties_test.cc          | 112 +++++++++++++
 python/README-zh.md                                |  18 ++-
 python/README.md                                   |  16 ++
 python/tests/test_tsfile_properties.py             |  98 ++++++++++++
 python/tsfile/tsfile_cpp.pxd                       |  16 ++
 python/tsfile/tsfile_reader.pyx                    |  42 ++++-
 python/tsfile/tsfile_table_writer.py               |   6 +
 python/tsfile/tsfile_writer.pyx                    |  37 ++++-
 28 files changed, 1208 insertions(+), 54 deletions(-)

diff --git a/cpp/README-zh.md b/cpp/README-zh.md
index a8af952b1..b0d76f506 100644
--- a/cpp/README-zh.md
+++ b/cpp/README-zh.md
@@ -188,3 +188,22 @@ bash build.sh
 ```
 
 即可在 `./examples/build` 目录下生成可执行文件。
+
+### 文件级 Properties
+
+`TsFileWriter` 和 `TsFileTableWriter` 可以在 writer 打开期间新增或覆盖二进制
+property。传入的数据会立即复制,调用 `flush()` 后仍可继续修改;文件关闭后不能修改。
+
+```cpp
+std::vector<uint8_t> value = {0x01, 0x00, 0xFF};
+writer.add_tsfile_property("binary-property", value);
+
+// nullptr 且长度为 0 表示 null;空 vector 表示非 null 的零长度值。
+writer.add_tsfile_property("null-property", nullptr, 0);
+writer.add_tsfile_property("empty-property", std::vector<uint8_t>());
+
+storage::TsFileProperties properties = reader.get_tsfile_properties();
+```
+
+Property value 本身不保存数据类型。整数、浮点数或结构体应由应用使用明确、可跨语言的
+字节编码进行转换。
diff --git a/cpp/README.md b/cpp/README.md
index 918eff68f..5f62afe24 100644
--- a/cpp/README.md
+++ b/cpp/README.md
@@ -203,3 +203,23 @@ By default, parallel write is enabled when the machine has 
more than one CPU cor
 ## Use TsFile
 
 You can find examples on how to read and write data in `demo_read.cpp` and 
`demo_write.cpp` located under `./examples/cpp_examples`. There are also 
examples under `./examples/c_examples` on how to use a C-style API to read and 
write data in a C environment. The examples will be built automatically when 
you run the main build command.
+
+### File-level properties
+
+`TsFileWriter` and `TsFileTableWriter` can add or replace binary properties
+while the writer is open. Values are copied immediately and may still be
+changed after `flush()`; a closed file cannot be modified.
+
+```cpp
+std::vector<uint8_t> value = {0x01, 0x00, 0xFF};
+writer.add_tsfile_property("binary-property", value);
+
+// nullptr with length 0 is null; an empty vector is a non-null empty value.
+writer.add_tsfile_property("null-property", nullptr, 0);
+writer.add_tsfile_property("empty-property", std::vector<uint8_t>());
+
+storage::TsFileProperties properties = reader.get_tsfile_properties();
+```
+
+Property values do not store a data type. Applications should define their own
+portable byte encoding for integers, floating-point values, or structures.
diff --git a/cpp/src/common/tsfile_common.cc b/cpp/src/common/tsfile_common.cc
index a3fcc0a70..3cac258ff 100644
--- a/cpp/src/common/tsfile_common.cc
+++ b/cpp/src/common/tsfile_common.cc
@@ -20,6 +20,7 @@
 #include "common/tsfile_common.h"
 
 #include <algorithm>
+#include <limits>
 #include <map>
 
 #include "common/logger/elog.h"
@@ -180,37 +181,101 @@ int TSMIterator::get_next(std::shared_ptr<IDeviceID>& 
ret_device_id,
     return ret;
 }
 
-int TsFileMeta::serialize_to(common::ByteStream& out) {
+int TsFileMeta::serialize_to(common::ByteStream& out,
+                             int32_t& serialized_size) {
+    serialized_size = 0;
+    const size_t max_property_size =
+        static_cast<size_t>(std::numeric_limits<int32_t>::max());
+    if (tsfile_properties_.size() > max_property_size) {
+        return common::E_OUT_OF_RANGE;
+    }
+    for (const auto& tsfile_property : tsfile_properties_) {
+        if (tsfile_property.first.size() > max_property_size ||
+            (!tsfile_property.second.is_null &&
+             tsfile_property.second.value.size() > max_property_size)) {
+            return common::E_OUT_OF_RANGE;
+        }
+    }
+
+    int ret = common::E_OK;
     auto start_idx = out.total_size();
-    common::SerializationUtil::write_var_uint(
-        table_metadata_index_node_map_.size(), out);
+    if (RET_FAIL(common::SerializationUtil::write_var_uint(
+            table_metadata_index_node_map_.size(), out))) {
+        return ret;
+    }
     for (auto& idx_nodes_iter : table_metadata_index_node_map_) {
-        common::SerializationUtil::write_var_str(idx_nodes_iter.first, out);
-        idx_nodes_iter.second->serialize_to(out);
+        if (RET_FAIL(common::SerializationUtil::write_var_str(
+                idx_nodes_iter.first, out))) {
+            return ret;
+        } else if (RET_FAIL(idx_nodes_iter.second->serialize_to(out))) {
+            return ret;
+        }
     }
 
-    common::SerializationUtil::write_var_uint(table_schemas_.size(), out);
+    if (RET_FAIL(common::SerializationUtil::write_var_uint(
+            table_schemas_.size(), out))) {
+        return ret;
+    }
     for (auto& table_schema_iter : table_schemas_) {
-        common::SerializationUtil::write_var_str(table_schema_iter.first, out);
-        table_schema_iter.second->serialize_to(out);
+        if (RET_FAIL(common::SerializationUtil::write_var_str(
+                table_schema_iter.first, out))) {
+            return ret;
+        } else if (RET_FAIL(table_schema_iter.second->serialize_to(out))) {
+            return ret;
+        }
     }
 
-    common::SerializationUtil::write_i64(meta_offset_, out);
+    if (RET_FAIL(common::SerializationUtil::write_i64(meta_offset_, out))) {
+        return ret;
+    }
 
     if (bloom_filter_ != nullptr) {
-        bloom_filter_->serialize_to(out);
+        if (RET_FAIL(bloom_filter_->serialize_to(out))) {
+            return ret;
+        }
     } else {
-        common::SerializationUtil::write_ui8(0, out);
+        if (RET_FAIL(common::SerializationUtil::write_ui8(0, out))) {
+            return ret;
+        }
     }
 
-    common::SerializationUtil::write_var_int(tsfile_properties_.size(), out);
+    if (RET_FAIL(common::SerializationUtil::write_var_int(
+            static_cast<int32_t>(tsfile_properties_.size()), out))) {
+        return ret;
+    }
     for (const auto& tsfile_property : tsfile_properties_) {
-        common::SerializationUtil::write_var_str(tsfile_property.first, out);
-        common::SerializationUtil::write_var_char_ptr(tsfile_property.second,
-                                                      out);
+        if (RET_FAIL(common::SerializationUtil::write_var_str(
+                tsfile_property.first, out))) {
+            return ret;
+        }
+        const TsFilePropertyValue& value = tsfile_property.second;
+        if (value.is_null) {
+            if (RET_FAIL(common::SerializationUtil::write_var_int(
+                    NO_STR_TO_READ, out))) {
+                return ret;
+            }
+        } else {
+            if (RET_FAIL(common::SerializationUtil::write_var_int(
+                    static_cast<int32_t>(value.value.size()), out))) {
+                return ret;
+            }
+            if (!value.value.empty()) {
+                if (RET_FAIL(out.write_buf(
+                        value.value.data(),
+                        static_cast<uint32_t>(value.value.size())))) {
+                    return ret;
+                }
+            }
+        }
     }
 
-    return out.total_size() - start_idx;
+    const uint64_t total_size = out.total_size() - start_idx;
+    if (total_size >
+        static_cast<uint64_t>(std::numeric_limits<int32_t>::max())) {
+        return common::E_OUT_OF_RANGE;
+    }
+    serialized_size = static_cast<int32_t>(total_size);
+    return common::E_OK;
 }
 
 int TsFileMeta::deserialize_from(common::ByteStream& in) {
@@ -251,15 +316,67 @@ int TsFileMeta::deserialize_from(common::ByteStream& in) {
 
     common::SerializationUtil::read_i64(meta_offset_, in);
 
-    bloom_filter_->deserialize_from(in);
+    if (RET_FAIL(bloom_filter_->deserialize_from(in))) {
+        return ret;
+    }
 
     int32_t tsfile_properties_size = 0;
-    common::SerializationUtil::read_var_int(tsfile_properties_size, in);
+    if 
(RET_FAIL(common::SerializationUtil::read_var_int(tsfile_properties_size,
+                                                         in))) {
+        return ret;
+    }
+    if (tsfile_properties_size < 0) {
+        return common::E_TSFILE_CORRUPTED;
+    }
     for (int i = 0; i < tsfile_properties_size; i++) {
-        std::string key, *value;
-        common::SerializationUtil::read_var_str(key, in);
-        common::SerializationUtil::read_var_char_ptr(value, in);
-        tsfile_properties_.emplace(key, value);
+        std::string key;
+        int32_t key_len = 0;
+        int32_t value_len = 0;
+        if (RET_FAIL(common::SerializationUtil::read_var_int(key_len, in))) {
+            return ret;
+        } else if (key_len < 0) {
+            return common::E_TSFILE_CORRUPTED;
+        }
+        if (static_cast<uint64_t>(key_len) > in.remaining_size()) {
+            return common::E_TSFILE_CORRUPTED;
+        }
+        key.resize(static_cast<size_t>(key_len));
+        if (key_len > 0) {
+            uint32_t read_len = 0;
+            if (RET_FAIL(in.read_buf(reinterpret_cast<uint8_t*>(&key[0]),
+                                     static_cast<uint32_t>(key_len),
+                                     read_len))) {
+                return ret;
+            } else if (read_len != static_cast<uint32_t>(key_len)) {
+                return common::E_BUF_NOT_ENOUGH;
+            }
+        }
+        if (RET_FAIL(common::SerializationUtil::read_var_int(value_len, in))) {
+            return ret;
+        }
+
+        TsFilePropertyValue value;
+        if (value_len == NO_STR_TO_READ) {
+            value.is_null = true;
+        } else if (value_len < 0) {
+            return common::E_TSFILE_CORRUPTED;
+        } else {
+            if (static_cast<uint64_t>(value_len) > in.remaining_size()) {
+                return common::E_TSFILE_CORRUPTED;
+            }
+            value.is_null = false;
+            value.value.resize(static_cast<size_t>(value_len));
+            if (value_len > 0) {
+                uint32_t read_len = 0;
+                if (RET_FAIL(
+                        in.read_buf(value.value.data(), value_len, read_len))) 
{
+                    return ret;
+                } else if (read_len != static_cast<uint32_t>(value_len)) {
+                    return common::E_BUF_NOT_ENOUGH;
+                }
+            }
+        }
+        tsfile_properties_.emplace(key, std::move(value));
     }
     return ret;
 }
@@ -375,4 +492,4 @@ int MetaIndexNode::binary_search_children(const String 
&name,
 }
 #endif
 
-}  // end namespace storage
\ No newline at end of file
+}  // end namespace storage
diff --git a/cpp/src/common/tsfile_common.h b/cpp/src/common/tsfile_common.h
index fd3690200..d763acbbd 100644
--- a/cpp/src/common/tsfile_common.h
+++ b/cpp/src/common/tsfile_common.h
@@ -1126,13 +1126,35 @@ struct MetaIndexNode {
 
 class TableSchema;
 
+struct TsFilePropertyValue {
+    /** A default-constructed property represents a null value. */
+    TsFilePropertyValue() : is_null(true), value() {}
+
+    /** A vector, including an empty vector, represents a non-null value. */
+    explicit TsFilePropertyValue(const std::vector<uint8_t>& value)
+        : is_null(false), value(value) {}
+
+    /** nullptr represents null; a non-null pointer with length 0 is empty. */
+    TsFilePropertyValue(const uint8_t* data, uint32_t value_len)
+        : is_null(data == nullptr), value() {
+        if (data != nullptr && value_len > 0) {
+            value.assign(data, data + value_len);
+        }
+    }
+
+    bool is_null;
+    std::vector<uint8_t> value;
+};
+
+using TsFileProperties = std::unordered_map<std::string, TsFilePropertyValue>;
+
 struct TsFileMeta {
     typedef std::map<std::shared_ptr<IDeviceID>, 
std::shared_ptr<MetaIndexNode>,
                      IDeviceIDComparator>
         DeviceNodeMap;
     std::map<std::string, std::shared_ptr<MetaIndexNode>>
         table_metadata_index_node_map_;
-    std::unordered_map<std::string, std::string*> tsfile_properties_;
+    TsFileProperties tsfile_properties_;
     typedef std::unordered_map<std::string, std::shared_ptr<TableSchema>>
         TableSchemasMap;
     TableSchemasMap table_schemas_;
@@ -1170,18 +1192,12 @@ struct TsFileMeta {
         if (bloom_filter_ != nullptr) {
             bloom_filter_->destroy();
         }
-        for (auto properties : tsfile_properties_) {
-            if (properties.second != nullptr) {
-                delete properties.second;
-                properties.second = nullptr;
-            }
-        }
         tsfile_properties_.clear();
         table_metadata_index_node_map_.clear();
         table_schemas_.clear();
     }
 
-    int serialize_to(common::ByteStream& out);
+    int serialize_to(common::ByteStream& out, int32_t& serialized_size);
 
     int deserialize_from(common::ByteStream& in);
 
diff --git a/cpp/src/cwrapper/tsfile_cwrapper.cc 
b/cpp/src/cwrapper/tsfile_cwrapper.cc
index ffe2f59b6..3e27f60d0 100644
--- a/cpp/src/cwrapper/tsfile_cwrapper.cc
+++ b/cpp/src/cwrapper/tsfile_cwrapper.cc
@@ -31,6 +31,8 @@
 #endif
 
 #include <cstring>
+#include <limits>
+#include <new>
 #include <set>
 #include <vector>
 
@@ -258,6 +260,27 @@ ERRNO tsfile_writer_close(TsFileWriter writer) {
     return ret;
 }
 
+ERRNO tsfile_writer_add_tsfile_property(TsFileWriter writer, const char* key,
+                                        uint32_t key_len, const uint8_t* value,
+                                        uint32_t value_len) {
+    if (writer == nullptr || key == nullptr ||
+        (value == nullptr && value_len > 0)) {
+        return common::E_INVALID_ARG;
+    }
+    if (key_len > static_cast<uint32_t>(std::numeric_limits<int32_t>::max()) ||
+        value_len >
+            static_cast<uint32_t>(std::numeric_limits<int32_t>::max())) {
+        return common::E_OUT_OF_RANGE;
+    }
+    try {
+        auto* w = static_cast<storage::TsFileTableWriter*>(writer);
+        return w->add_tsfile_property(std::string(key, key_len), value,
+                                      value_len);
+    } catch (const std::bad_alloc&) {
+        return common::E_OOM;
+    }
+}
+
 ERRNO tsfile_reader_close(TsFileReader reader) {
     auto* ts_reader = static_cast<storage::TsFileReader*>(reader);
     delete ts_reader;
@@ -1432,6 +1455,110 @@ void tsfile_free_device_timeseries_metadata_map(
     map->device_count = 0;
 }
 
+void tsfile_free_tsfile_properties(TsFileProperty* properties,
+                                   uint32_t length) {
+    if (properties == nullptr) {
+        return;
+    }
+    for (uint32_t i = 0; i < length; i++) {
+        free(properties[i].key);
+        properties[i].key = nullptr;
+        free(properties[i].value);
+        properties[i].value = nullptr;
+        properties[i].key_len = 0;
+        properties[i].value_len = 0;
+        properties[i].is_null = false;
+    }
+    free(properties);
+}
+
+ERRNO tsfile_reader_get_tsfile_properties(TsFileReader reader,
+                                          TsFileProperty** out_properties,
+                                          uint32_t* out_length) {
+    if (out_properties == nullptr || out_length == nullptr) {
+        return common::E_INVALID_ARG;
+    }
+    *out_properties = nullptr;
+    *out_length = 0;
+    if (reader == nullptr) {
+        return common::E_INVALID_ARG;
+    }
+
+    try {
+        auto* r = static_cast<storage::TsFileReader*>(reader);
+        storage::TsFileProperties cpp_properties = r->get_tsfile_properties();
+        if (cpp_properties.size() >
+                static_cast<size_t>(std::numeric_limits<uint32_t>::max()) ||
+            cpp_properties.size() >
+                std::numeric_limits<size_t>::max() / sizeof(TsFileProperty)) {
+            return common::E_OUT_OF_RANGE;
+        }
+        if (cpp_properties.empty()) {
+            return common::E_OK;
+        }
+
+        auto* properties = static_cast<TsFileProperty*>(
+            malloc(sizeof(TsFileProperty) * cpp_properties.size()));
+        if (properties == nullptr) {
+            return common::E_OOM;
+        }
+        memset(properties, 0, sizeof(TsFileProperty) * cpp_properties.size());
+
+        uint32_t property_index = 0;
+        for (const auto& cpp_property : cpp_properties) {
+            TsFileProperty& property = properties[property_index];
+            if (cpp_property.first.size() >
+                static_cast<size_t>(std::numeric_limits<uint32_t>::max())) {
+                tsfile_free_tsfile_properties(properties, property_index);
+                return common::E_OUT_OF_RANGE;
+            }
+            property.key_len = 
static_cast<uint32_t>(cpp_property.first.size());
+            property.key = static_cast<char*>(
+                malloc(static_cast<size_t>(property.key_len) + 1U));
+            if (property.key == nullptr) {
+                tsfile_free_tsfile_properties(properties, property_index + 1);
+                return common::E_OOM;
+            }
+            if (property.key_len > 0) {
+                memcpy(property.key, cpp_property.first.data(),
+                       property.key_len);
+            }
+            property.key[property.key_len] = '\0';
+
+            const storage::TsFilePropertyValue& cpp_value = 
cpp_property.second;
+            property.is_null = cpp_value.is_null;
+            if (!cpp_value.is_null) {
+                if (cpp_value.value.size() >
+                    static_cast<size_t>(std::numeric_limits<uint32_t>::max())) 
{
+                    tsfile_free_tsfile_properties(properties,
+                                                  property_index + 1);
+                    return common::E_OUT_OF_RANGE;
+                }
+                property.value_len =
+                    static_cast<uint32_t>(cpp_value.value.size());
+                if (property.value_len > 0) {
+                    property.value =
+                        static_cast<uint8_t*>(malloc(property.value_len));
+                    if (property.value == nullptr) {
+                        tsfile_free_tsfile_properties(properties,
+                                                      property_index + 1);
+                        return common::E_OOM;
+                    }
+                    memcpy(property.value, cpp_value.value.data(),
+                           property.value_len);
+                }
+            }
+            property_index++;
+        }
+
+        *out_properties = properties;
+        *out_length = static_cast<uint32_t>(cpp_properties.size());
+        return common::E_OK;
+    } catch (const std::bad_alloc&) {
+        return common::E_OOM;
+    }
+}
+
 // delete pointer
 void _free_tsfile_ts_record(TsRecord* record) {
     if (*record != nullptr) {
@@ -1640,6 +1767,27 @@ ERRNO _tsfile_writer_flush(TsFileWriter writer) {
     return w->flush();
 }
 
+ERRNO _tsfile_writer_add_tsfile_property(TsFileWriter writer, const char* key,
+                                         uint32_t key_len, const uint8_t* 
value,
+                                         uint32_t value_len) {
+    if (writer == nullptr || key == nullptr ||
+        (value == nullptr && value_len > 0)) {
+        return common::E_INVALID_ARG;
+    }
+    if (key_len > static_cast<uint32_t>(std::numeric_limits<int32_t>::max()) ||
+        value_len >
+            static_cast<uint32_t>(std::numeric_limits<int32_t>::max())) {
+        return common::E_OUT_OF_RANGE;
+    }
+    try {
+        auto* w = static_cast<storage::TsFileWriter*>(writer);
+        return w->add_tsfile_property(std::string(key, key_len), value,
+                                      value_len);
+    } catch (const std::bad_alloc&) {
+        return common::E_OOM;
+    }
+}
+
 ResultSet _tsfile_reader_query_device(TsFileReader reader,
                                       const char* device_name,
                                       char** sensor_name, uint32_t sensor_num,
diff --git a/cpp/src/cwrapper/tsfile_cwrapper.h 
b/cpp/src/cwrapper/tsfile_cwrapper.h
index 768aec962..16d392bae 100644
--- a/cpp/src/cwrapper/tsfile_cwrapper.h
+++ b/cpp/src/cwrapper/tsfile_cwrapper.h
@@ -230,6 +230,21 @@ typedef struct DeviceTimeseriesMetadataMap {
     uint32_t device_count;
 } DeviceTimeseriesMetadataMap;
 
+/**
+ * @brief One file-level property with length-aware binary storage.
+ *
+ * @p key is allocated with one trailing NUL for convenience, while @p key_len
+ * is authoritative and preserves embedded NUL bytes. @p is_null distinguishes
+ * a null value from a non-null zero-length value.
+ */
+typedef struct TsFileProperty {
+    char* key;
+    uint32_t key_len;
+    uint8_t* value;
+    uint32_t value_len;
+    bool is_null;
+} TsFileProperty;
+
 /** Frees path, table_name, and segments inside @p d; zeros @p d. */
 void tsfile_device_id_free_contents(DeviceID* d);
 
@@ -435,6 +450,17 @@ TsFileReader tsfile_reader_new(const char* pathname, 
ERRNO* err_code);
  */
 ERRNO tsfile_writer_close(TsFileWriter writer);
 
+/**
+ * @brief Adds or replaces a file-level property while the table writer is 
open.
+ *
+ * The key and value are copied immediately. A NULL value with value_len == 0
+ * represents a null property; a non-NULL value with value_len == 0 represents
+ * an empty byte array.
+ */
+ERRNO tsfile_writer_add_tsfile_property(TsFileWriter writer, const char* key,
+                                        uint32_t key_len, const uint8_t* value,
+                                        uint32_t value_len);
+
 /**
  * @brief Releases resources associated with a TsFileReader.
  *
@@ -477,6 +503,17 @@ ERRNO tsfile_reader_get_timeseries_metadata_for_devices(
 void tsfile_free_device_timeseries_metadata_map(
     DeviceTimeseriesMetadataMap* map);
 
+/**
+ * @brief Returns a heap-allocated array containing all file-level properties.
+ *
+ * Caller must release the result with tsfile_free_tsfile_properties().
+ */
+ERRNO tsfile_reader_get_tsfile_properties(TsFileReader reader,
+                                          TsFileProperty** out_properties,
+                                          uint32_t* out_length);
+
+void tsfile_free_tsfile_properties(TsFileProperty* properties, uint32_t 
length);
+
 /*--------------------------Tablet API------------------------ */
 
 /**
@@ -1055,6 +1092,11 @@ ERRNO _tsfile_writer_close(TsFileWriter writer);
 // Flush Chunk into tsfile from current tsFileWriter
 ERRNO _tsfile_writer_flush(TsFileWriter writer);
 
+// Add or replace a file-level property on the generic writer used by Python.
+ERRNO _tsfile_writer_add_tsfile_property(TsFileWriter writer, const char* key,
+                                         uint32_t key_len, const uint8_t* 
value,
+                                         uint32_t value_len);
+
 // Queries time-series data for a specific device within a given time range.
 ResultSet _tsfile_reader_query_device(TsFileReader reader,
                                       const char* device_name,
diff --git a/cpp/src/file/tsfile_io_writer.cc b/cpp/src/file/tsfile_io_writer.cc
index 8c207ca82..29ddf0d90 100644
--- a/cpp/src/file/tsfile_io_writer.cc
+++ b/cpp/src/file/tsfile_io_writer.cc
@@ -23,6 +23,7 @@
 
 #include <chrono>
 #include <iomanip>
+#include <limits>
 #include <memory>
 
 #include "common/device_id.h"
@@ -93,6 +94,7 @@ void TsFileIOWriter::destroy() {
     use_prev_alloc_cgm_ = false;
     is_aligned_ = false;
     file_base_offset_ = 0;
+    tsfile_properties_.clear();
     destroyed_ = true;
 
     meta_allocator_.destroy();
@@ -103,6 +105,38 @@ void TsFileIOWriter::destroy() {
     }
 }
 
+int TsFileIOWriter::add_tsfile_property(const std::string& key,
+                                        const uint8_t* value,
+                                        uint32_t value_len) {
+    if (file_ == nullptr || file_->get_fd() < 0) {
+        return common::E_FILE_WRITE_ERR;
+    }
+    if (value_len > 0 && value == nullptr) {
+        return common::E_INVALID_ARG;
+    }
+    if (key.size() > static_cast<size_t>(std::numeric_limits<int32_t>::max()) 
||
+        value_len >
+            static_cast<uint32_t>(std::numeric_limits<int32_t>::max())) {
+        return common::E_OUT_OF_RANGE;
+    }
+    tsfile_properties_[key] = TsFilePropertyValue(value, value_len);
+    return common::E_OK;
+}
+
+int TsFileIOWriter::add_tsfile_property(const std::string& key,
+                                        const std::vector<uint8_t>& value) {
+    if (file_ == nullptr || file_->get_fd() < 0) {
+        return common::E_FILE_WRITE_ERR;
+    }
+    if (key.size() > static_cast<size_t>(std::numeric_limits<int32_t>::max()) 
||
+        value.size() >
+            static_cast<size_t>(std::numeric_limits<int32_t>::max())) {
+        return common::E_OUT_OF_RANGE;
+    }
+    tsfile_properties_[key] = TsFilePropertyValue(value);
+    return common::E_OK;
+}
+
 int TsFileIOWriter::start_file() {
     int ret = E_OK;
     if (RET_FAIL(write_buf(MAGIC_STRING_TSFILE, MAGIC_STRING_TSFILE_LEN))) {
@@ -472,18 +506,23 @@ int TsFileIOWriter::write_file_index() {
         }
         tsfile_meta.table_metadata_index_node_map_ = table_nodes_map;
         tsfile_meta.table_schemas_ = schema_->table_schema_map_;
-        tsfile_meta.tsfile_properties_.insert(
-            std::make_pair("encryptLevel", new std::string(encrypt_level_)));
-        tsfile_meta.tsfile_properties_.insert(
-            std::make_pair("encryptType", new std::string(encrypt_type_)));
-        tsfile_meta.tsfile_properties_.insert(
-            std::make_pair("encryptKey", nullptr));
+        tsfile_meta.tsfile_properties_ = tsfile_properties_;
+        tsfile_meta.tsfile_properties_["encryptLevel"] = TsFilePropertyValue(
+            reinterpret_cast<const uint8_t*>(encrypt_level_.data()),
+            static_cast<uint32_t>(encrypt_level_.size()));
+        tsfile_meta.tsfile_properties_["encryptType"] = TsFilePropertyValue(
+            reinterpret_cast<const uint8_t*>(encrypt_type_.data()),
+            static_cast<uint32_t>(encrypt_type_.size()));
+        tsfile_meta.tsfile_properties_["encryptKey"] = TsFilePropertyValue();
 #if DEBUG_SE
         auto tsfile_meta_offset = write_stream_.total_size();
 #endif
-        auto total_write_size = tsfile_meta.serialize_to(write_stream_);
-        if (RET_FAIL(common::SerializationUtil::write_i32(total_write_size,
-                                                          write_stream_))) {
+        int32_t total_write_size = 0;
+        if (RET_FAIL(
+                tsfile_meta.serialize_to(write_stream_, total_write_size))) {
+            return ret;
+        } else if (RET_FAIL(common::SerializationUtil::write_i32(
+                       total_write_size, write_stream_))) {
             return ret;
         }
         tsfile_meta.bloom_filter_ = nullptr;
diff --git a/cpp/src/file/tsfile_io_writer.h b/cpp/src/file/tsfile_io_writer.h
index f041a1c57..bbb1e4988 100644
--- a/cpp/src/file/tsfile_io_writer.h
+++ b/cpp/src/file/tsfile_io_writer.h
@@ -89,6 +89,10 @@ class TsFileIOWriter {
     void destroy();
 
     void set_generate_table_schema(bool generate_table_schema);
+    int add_tsfile_property(const std::string& key, const uint8_t* value,
+                            uint32_t value_len);
+    int add_tsfile_property(const std::string& key,
+                            const std::vector<uint8_t>& value);
     int start_file();
     int start_flush_chunk_group(std::shared_ptr<IDeviceID> device_id,
                                 bool is_aligned = false);
@@ -242,6 +246,7 @@ class TsFileIOWriter {
     std::string encrypt_level_;
     std::string encrypt_type_;
     std::string encrypt_key_;
+    TsFileProperties tsfile_properties_;
     bool is_aligned_;
     /** Recovery only: absolute file offset at which write_stream_ logically
      * begins.  Normal (non-recovery) path keeps this at 0. */
diff --git a/cpp/src/file/write_file.cc b/cpp/src/file/write_file.cc
index 227520b71..68ac127ac 100644
--- a/cpp/src/file/write_file.cc
+++ b/cpp/src/file/write_file.cc
@@ -52,6 +52,12 @@ int WriteFile::create(const std::string& file_path, int 
flags, mode_t mode) {
 
 int WriteFile::do_create(int flags, mode_t mode) {
     int ret = E_OK;
+#ifdef _WIN32
+    // TsFile is a binary format. Callers of the C++ API may pass ordinary
+    // POSIX-style flags without O_BINARY; leaving the descriptor in text mode
+    // would translate byte 0x0A to 0x0D 0x0A and corrupt serialized metadata.
+    flags |= O_BINARY;
+#endif
     // TODO make sure no same file exists
     fd_ = ::open(path_.c_str(), flags, mode);
     if (fd_ < 0) {
diff --git a/cpp/src/reader/bloom_filter.cc b/cpp/src/reader/bloom_filter.cc
index 4aff4ecd3..09e2c9a15 100644
--- a/cpp/src/reader/bloom_filter.cc
+++ b/cpp/src/reader/bloom_filter.cc
@@ -235,11 +235,12 @@ int BloomFilter::serialize_to(ByteStream& out) {
     bitset_.to_bytes(filter_data_bytes, filter_data_bytes_len);
     if (RET_FAIL(
             SerializationUtil::write_var_uint(filter_data_bytes_len, out))) {
-    } else if (RET_FAIL(
-                   out.write_buf(filter_data_bytes, filter_data_bytes_len))) {
-    } else if (RET_FAIL(SerializationUtil::write_var_uint(size_, out))) {
-    } else if (RET_FAIL(
-                   SerializationUtil::write_var_uint(hash_func_count_, out))) {
+    } else if (filter_data_bytes_len > 0) {
+        if (RET_FAIL(out.write_buf(filter_data_bytes, filter_data_bytes_len))) 
{
+        } else if (RET_FAIL(SerializationUtil::write_var_uint(size_, out))) {
+        } else if (RET_FAIL(SerializationUtil::write_var_uint(hash_func_count_,
+                                                              out))) {
+        }
     }
     if (filter_data_bytes_len > 0) {
         bitset_.revert_bytes(filter_data_bytes);
@@ -253,6 +254,31 @@ int BloomFilter::deserialize_from(ByteStream& in) {
     uint32_t ret_read_len = 0;
     uint8_t* filter_data = nullptr;
     if (RET_FAIL(SerializationUtil::read_var_uint(filter_data_bytes_len, in))) 
{
+    } else if (filter_data_bytes_len == 0) {
+        // Older C++ writers serialized an empty filter as three zero varints:
+        // byte length, bit count, and hash-function count. The Java-compatible
+        // encoding contains only the byte length. Probe the two legacy fields
+        // and restore the cursor when the following data is instead the
+        // TsFile property count from the current encoding.
+        const uint64_t legacy_fields_pos = in.read_pos();
+        if (in.remaining_size() >= 2) {
+            uint32_t legacy_size = 0;
+            uint32_t legacy_hash_func_count = 0;
+            int probe_ret = SerializationUtil::read_var_uint(legacy_size, in);
+            if (probe_ret == E_OK && legacy_size == 0) {
+                probe_ret = SerializationUtil::read_var_uint(
+                    legacy_hash_func_count, in);
+            }
+            if (probe_ret != E_OK || legacy_size != 0 ||
+                legacy_hash_func_count != 0) {
+                in.set_read_pos(legacy_fields_pos);
+            }
+        }
+        size_ = 0;
+        hash_func_count_ = 0;
+        return E_OK;
+    } else if (filter_data_bytes_len > in.remaining_size()) {
+        ret = E_TSFILE_CORRUPTED;
     } else if (UNLIKELY(nullptr ==
                         (filter_data = (uint8_t*)mem_alloc(
                              filter_data_bytes_len, MOD_BLOOM_FILTER)))) {
@@ -264,6 +290,9 @@ int BloomFilter::deserialize_from(ByteStream& in) {
     } else if (RET_FAIL(SerializationUtil::read_var_uint(size_, in))) {
     } else if (RET_FAIL(
                    SerializationUtil::read_var_uint(hash_func_count_, in))) {
+    } else if (size_ == 0 || hash_func_count_ == 0 ||
+               hash_func_count_ > MAX_HASH_FUNC_COUNT) {
+        ret = E_TSFILE_CORRUPTED;
     } else {
         for (uint32_t i = 0; i < hash_func_count_; i++) {
             hash_func_arr_[i].init(size_, SEEDS[i]);
diff --git a/cpp/src/reader/tsfile_reader.cc b/cpp/src/reader/tsfile_reader.cc
index 33d0d8967..fb5f8fd92 100644
--- a/cpp/src/reader/tsfile_reader.cc
+++ b/cpp/src/reader/tsfile_reader.cc
@@ -527,6 +527,15 @@ DeviceTimeseriesMetadataMap 
TsFileReader::get_timeseries_metadata() {
     return result;
 }
 
+TsFileProperties TsFileReader::get_tsfile_properties() {
+    if (tsfile_executor_ == nullptr) {
+        return TsFileProperties();
+    }
+    TsFileMeta* file_metadata = tsfile_executor_->get_tsfile_meta();
+    return file_metadata == nullptr ? TsFileProperties()
+                                    : file_metadata->tsfile_properties_;
+}
+
 ResultSet* TsFileReader::read_timeseries(
     const std::shared_ptr<IDeviceID>& device_id,
     const std::vector<std::string>& measurement_name) {
diff --git a/cpp/src/reader/tsfile_reader.h b/cpp/src/reader/tsfile_reader.h
index e2f9f3496..3ba490045 100644
--- a/cpp/src/reader/tsfile_reader.h
+++ b/cpp/src/reader/tsfile_reader.h
@@ -216,6 +216,9 @@ class TsFileReader {
      */
     DeviceTimeseriesMetadataMap get_timeseries_metadata();
 
+    /** Return a copy of all file-level properties, preserving null values. */
+    TsFileProperties get_tsfile_properties();
+
     /**
      * @brief get the table schema by the table name
      *
diff --git a/cpp/src/writer/tsfile_table_writer.cc 
b/cpp/src/writer/tsfile_table_writer.cc
index b1b7911bd..5432aff5c 100644
--- a/cpp/src/writer/tsfile_table_writer.cc
+++ b/cpp/src/writer/tsfile_table_writer.cc
@@ -92,6 +92,23 @@ int storage::TsFileTableWriter::flush() {
     return tsfile_writer_->flush();
 }
 
+int storage::TsFileTableWriter::add_tsfile_property(const std::string& key,
+                                                    const uint8_t* value,
+                                                    uint32_t value_len) {
+    if (closed_ || !tsfile_writer_) {
+        return common::E_FILE_WRITE_ERR;
+    }
+    return tsfile_writer_->add_tsfile_property(key, value, value_len);
+}
+
+int storage::TsFileTableWriter::add_tsfile_property(
+    const std::string& key, const std::vector<uint8_t>& value) {
+    if (closed_ || !tsfile_writer_) {
+        return common::E_FILE_WRITE_ERR;
+    }
+    return tsfile_writer_->add_tsfile_property(key, value);
+}
+
 int storage::TsFileTableWriter::close() {
     if (closed_) {
         return common::E_OK;
diff --git a/cpp/src/writer/tsfile_table_writer.h 
b/cpp/src/writer/tsfile_table_writer.h
index a2d2a5fd9..d7c79254f 100644
--- a/cpp/src/writer/tsfile_table_writer.h
+++ b/cpp/src/writer/tsfile_table_writer.h
@@ -106,6 +106,12 @@ class TsFileTableWriter {
      * @return Returns 0 on success, or a non-zero error code on failure.
      */
     int flush();
+
+    /** Add or replace a binary file-level property while the writer is open. 
*/
+    int add_tsfile_property(const std::string& key, const uint8_t* value,
+                            uint32_t value_len);
+    int add_tsfile_property(const std::string& key,
+                            const std::vector<uint8_t>& value);
     /**
      * Closes the writer and releases any resources held by it.
      * After calling this method, no further operations should be performed on
diff --git a/cpp/src/writer/tsfile_writer.cc b/cpp/src/writer/tsfile_writer.cc
index 0b4c8668c..aa0e555f8 100644
--- a/cpp/src/writer/tsfile_writer.cc
+++ b/cpp/src/writer/tsfile_writer.cc
@@ -1960,4 +1960,21 @@ int TsFileWriter::close() {
     return io_writer_->end_file();
 }
 
+int TsFileWriter::add_tsfile_property(const std::string& key,
+                                      const uint8_t* value,
+                                      uint32_t value_len) {
+    if (io_writer_ == nullptr) {
+        return E_FILE_WRITE_ERR;
+    }
+    return io_writer_->add_tsfile_property(key, value, value_len);
+}
+
+int TsFileWriter::add_tsfile_property(const std::string& key,
+                                      const std::vector<uint8_t>& value) {
+    if (io_writer_ == nullptr) {
+        return E_FILE_WRITE_ERR;
+    }
+    return io_writer_->add_tsfile_property(key, value);
+}
+
 }  // end namespace storage
diff --git a/cpp/src/writer/tsfile_writer.h b/cpp/src/writer/tsfile_writer.h
index e0b102c97..55e9e7f3a 100644
--- a/cpp/src/writer/tsfile_writer.h
+++ b/cpp/src/writer/tsfile_writer.h
@@ -84,6 +84,12 @@ class TsFileWriter {
     int write_tree(const TsRecord& record);
     int write_table(Tablet& tablet);
 
+    /** Add or replace a binary file-level property while the writer is open. 
*/
+    int add_tsfile_property(const std::string& key, const uint8_t* value,
+                            uint32_t value_len);
+    int add_tsfile_property(const std::string& key,
+                            const std::vector<uint8_t>& value);
+
     typedef std::map<std::shared_ptr<IDeviceID>, MeasurementSchemaGroup*,
                      IDeviceIDComparator>
         DeviceSchemasMap;
diff --git a/cpp/test/common/tsfile_common_test.cc 
b/cpp/test/common/tsfile_common_test.cc
index 2108b2d02..5704afd19 100644
--- a/cpp/test/common/tsfile_common_test.cc
+++ b/cpp/test/common/tsfile_common_test.cc
@@ -449,19 +449,23 @@ TEST_F(TsFileMetaTest, SerializeDeserialize) {
         table_name, column_schemas, column_categories);
 
     meta_.table_schemas_.insert(std::make_pair(table_name, table_schema));
+    meta_.tsfile_properties_.insert(std::make_pair(
+        "key",
+        TsFilePropertyValue(std::vector<uint8_t>{'v', 'a', 'l', 'u', 'e'})));
     meta_.tsfile_properties_.insert(
-        std::make_pair("key", new std::string("value")));
-    meta_.tsfile_properties_.insert(std::make_pair("null_key", nullptr));
+        std::make_pair("null_key", TsFilePropertyValue()));
 
     meta_.meta_offset_ = 456;
     void* buf = pa_.alloc(sizeof(BloomFilter));
     meta_.bloom_filter_ = new (buf) BloomFilter();
     meta_.bloom_filter_->init(0.1, 100);
 
-    meta_.serialize_to(*out_);
+    int32_t serialized_size = 0;
+    ASSERT_EQ(common::E_OK, meta_.serialize_to(*out_, serialized_size));
+    ASSERT_EQ(serialized_size, out_->total_size());
 
     TsFileMeta new_meta(&pa_);
-    new_meta.deserialize_from(*out_);
+    ASSERT_EQ(common::E_OK, new_meta.deserialize_from(*out_));
 
     ASSERT_EQ(new_meta.meta_offset_, 456);
     ASSERT_EQ(new_meta.table_metadata_index_node_map_.size(), 1);
@@ -471,8 +475,70 @@ TEST_F(TsFileMetaTest, SerializeDeserialize) {
     ASSERT_EQ(new_meta.table_schemas_.size(), 1);
     ASSERT_EQ(
         new_meta.table_schemas_[table_name]->get_column_categories().size(), 
1);
-    ASSERT_EQ(*new_meta.tsfile_properties_["key"], std::string("value"));
-    ASSERT_EQ(new_meta.tsfile_properties_["null_key"], nullptr);
+    ASSERT_FALSE(new_meta.tsfile_properties_["key"].is_null);
+    ASSERT_EQ(new_meta.tsfile_properties_["key"].value,
+              (std::vector<uint8_t>{'v', 'a', 'l', 'u', 'e'}));
+    ASSERT_TRUE(new_meta.tsfile_properties_["null_key"].is_null);
+    ASSERT_TRUE(new_meta.tsfile_properties_["null_key"].value.empty());
+}
+
+TEST_F(TsFileMetaTest, DeserializesLegacyEmptyBloomFilterEncoding) {
+    ASSERT_EQ(common::E_OK,
+              common::SerializationUtil::write_var_uint(0, *out_));
+    ASSERT_EQ(common::E_OK,
+              common::SerializationUtil::write_var_uint(0, *out_));
+    ASSERT_EQ(common::E_OK, common::SerializationUtil::write_i64(0, *out_));
+    ASSERT_EQ(common::E_OK,
+              common::SerializationUtil::write_var_uint(0, *out_));
+    ASSERT_EQ(common::E_OK,
+              common::SerializationUtil::write_var_uint(0, *out_));
+    ASSERT_EQ(common::E_OK,
+              common::SerializationUtil::write_var_uint(0, *out_));
+    ASSERT_EQ(common::E_OK, common::SerializationUtil::write_var_int(1, 
*out_));
+    ASSERT_EQ(common::E_OK,
+              common::SerializationUtil::write_var_str("legacy", *out_));
+    ASSERT_EQ(common::E_OK, common::SerializationUtil::write_var_int(3, 
*out_));
+    ASSERT_EQ(common::E_OK, out_->write_buf("old", 3));
+
+    TsFileMeta meta(&pa_);
+    ASSERT_EQ(common::E_OK, meta.deserialize_from(*out_));
+    ASSERT_EQ(1U, meta.tsfile_properties_.size());
+    ASSERT_FALSE(meta.tsfile_properties_["legacy"].is_null);
+    EXPECT_EQ((std::vector<uint8_t>{'o', 'l', 'd'}),
+              meta.tsfile_properties_["legacy"].value);
+    EXPECT_EQ(0U, out_->remaining_size());
+}
+
+TEST_F(TsFileMetaTest, RejectsPropertyKeyLengthBeyondRemainingInput) {
+    ASSERT_EQ(common::E_OK,
+              common::SerializationUtil::write_var_uint(0, *out_));
+    ASSERT_EQ(common::E_OK,
+              common::SerializationUtil::write_var_uint(0, *out_));
+    ASSERT_EQ(common::E_OK, common::SerializationUtil::write_i64(0, *out_));
+    ASSERT_EQ(common::E_OK, common::SerializationUtil::write_ui8(0, *out_));
+    ASSERT_EQ(common::E_OK, common::SerializationUtil::write_var_int(1, 
*out_));
+    ASSERT_EQ(common::E_OK,
+              common::SerializationUtil::write_var_int(1024, *out_));
+
+    TsFileMeta meta(&pa_);
+    EXPECT_EQ(common::E_TSFILE_CORRUPTED, meta.deserialize_from(*out_));
+}
+
+TEST_F(TsFileMetaTest, RejectsPropertyValueLengthBeyondRemainingInput) {
+    ASSERT_EQ(common::E_OK,
+              common::SerializationUtil::write_var_uint(0, *out_));
+    ASSERT_EQ(common::E_OK,
+              common::SerializationUtil::write_var_uint(0, *out_));
+    ASSERT_EQ(common::E_OK, common::SerializationUtil::write_i64(0, *out_));
+    ASSERT_EQ(common::E_OK, common::SerializationUtil::write_ui8(0, *out_));
+    ASSERT_EQ(common::E_OK, common::SerializationUtil::write_var_int(1, 
*out_));
+    ASSERT_EQ(common::E_OK, common::SerializationUtil::write_var_int(1, 
*out_));
+    ASSERT_EQ(common::E_OK, out_->write_buf("k", 1));
+    ASSERT_EQ(common::E_OK,
+              common::SerializationUtil::write_var_int(1024, *out_));
+
+    TsFileMeta meta(&pa_);
+    EXPECT_EQ(common::E_TSFILE_CORRUPTED, meta.deserialize_from(*out_));
 }
 
 // Regression: the default-compression configuration must name a compressor
diff --git a/cpp/test/cwrapper/cwrapper_properties_test.cc 
b/cpp/test/cwrapper/cwrapper_properties_test.cc
new file mode 100644
index 000000000..7b2e43116
--- /dev/null
+++ b/cpp/test/cwrapper/cwrapper_properties_test.cc
@@ -0,0 +1,177 @@
+/*
+ * 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.
+ */
+
+#include <gtest/gtest.h>
+
+#include <cstdio>
+#include <cstring>
+#include <limits>
+#include <string>
+
+#include "cwrapper/tsfile_cwrapper.h"
+#include "utils/errno_define.h"
+
+namespace {
+
+const TsFileProperty* FindProperty(const TsFileProperty* properties,
+                                   uint32_t property_count,
+                                   const std::string& key) {
+    for (uint32_t i = 0; i < property_count; i++) {
+        if (properties[i].key_len == key.size() &&
+            std::memcmp(properties[i].key, key.data(), key.size()) == 0) {
+            return &properties[i];
+        }
+    }
+    return nullptr;
+}
+
+TEST(CWrapperPropertiesTest, GenericWriterRoundTripsLengthAwareValues) {
+    const char* file_name = "cwrapper_properties_test.tsfile";
+    std::remove(file_name);
+
+    ERRNO error_code = common::E_OK;
+    TsFileWriter writer =
+        _tsfile_writer_new(file_name, 128 * 1024 * 1024, &error_code);
+    ASSERT_NE(nullptr, writer);
+    ASSERT_EQ(common::E_OK, error_code);
+
+    const uint8_t first[] = {'f', 'i', 'r', 's', 't'};
+    const uint8_t binary[] = {0x00, 0xFF, 0x80, 0x01, 0x00};
+    const char embedded_null_key[] = {'k', '\0', 'y'};
+    const uint8_t empty_marker = 0;
+    EXPECT_EQ(common::E_INVALID_ARG,
+              _tsfile_writer_add_tsfile_property(nullptr, "key", 3, binary,
+                                                 sizeof(binary)));
+    EXPECT_EQ(common::E_INVALID_ARG,
+              _tsfile_writer_add_tsfile_property(writer, nullptr, 0, binary,
+                                                 sizeof(binary)));
+    EXPECT_EQ(common::E_INVALID_ARG,
+              _tsfile_writer_add_tsfile_property(writer, "key", 3, nullptr, 
1));
+    const uint32_t oversized_len =
+        static_cast<uint32_t>(std::numeric_limits<int32_t>::max()) + 1U;
+    EXPECT_EQ(common::E_OUT_OF_RANGE,
+              _tsfile_writer_add_tsfile_property(writer, "key", 3, binary,
+                                                 oversized_len));
+    ASSERT_EQ(common::E_OK,
+              _tsfile_writer_add_tsfile_property(writer, "overwritten", 11,
+                                                 first, sizeof(first)));
+    ASSERT_EQ(common::E_OK, _tsfile_writer_flush(writer));
+    ASSERT_EQ(common::E_OK,
+              _tsfile_writer_add_tsfile_property(writer, "overwritten", 11,
+                                                 binary, sizeof(binary)));
+    ASSERT_EQ(common::E_OK,
+              _tsfile_writer_add_tsfile_property(writer, embedded_null_key,
+                                                 sizeof(embedded_null_key),
+                                                 binary, sizeof(binary)));
+    ASSERT_EQ(common::E_OK, _tsfile_writer_add_tsfile_property(
+                                writer, "empty", 5, &empty_marker, 0));
+    ASSERT_EQ(common::E_OK, _tsfile_writer_add_tsfile_property(writer, "null",
+                                                               4, nullptr, 0));
+    ASSERT_EQ(common::E_OK, _tsfile_writer_close(writer));
+
+    TsFileReader reader = tsfile_reader_new(file_name, &error_code);
+    ASSERT_NE(nullptr, reader);
+    ASSERT_EQ(common::E_OK, error_code);
+    TsFileProperty* properties = nullptr;
+    uint32_t property_count = 0;
+    ASSERT_EQ(common::E_OK, tsfile_reader_get_tsfile_properties(
+                                reader, &properties, &property_count));
+
+    const TsFileProperty* overwritten =
+        FindProperty(properties, property_count, "overwritten");
+    ASSERT_NE(nullptr, overwritten);
+    EXPECT_FALSE(overwritten->is_null);
+    ASSERT_EQ(sizeof(binary), overwritten->value_len);
+    EXPECT_EQ(0, std::memcmp(binary, overwritten->value, sizeof(binary)));
+
+    const TsFileProperty* embedded_key_property =
+        FindProperty(properties, property_count,
+                     std::string(embedded_null_key, 
sizeof(embedded_null_key)));
+    ASSERT_NE(nullptr, embedded_key_property);
+    ASSERT_EQ(sizeof(binary), embedded_key_property->value_len);
+    EXPECT_EQ(
+        0, std::memcmp(binary, embedded_key_property->value, sizeof(binary)));
+
+    const TsFileProperty* empty =
+        FindProperty(properties, property_count, "empty");
+    ASSERT_NE(nullptr, empty);
+    EXPECT_FALSE(empty->is_null);
+    EXPECT_EQ(0U, empty->value_len);
+
+    const TsFileProperty* null_value =
+        FindProperty(properties, property_count, "null");
+    ASSERT_NE(nullptr, null_value);
+    EXPECT_TRUE(null_value->is_null);
+    EXPECT_EQ(0U, null_value->value_len);
+
+    tsfile_free_tsfile_properties(properties, property_count);
+    TsFileProperty sentinel{};
+    properties = &sentinel;
+    property_count = 1;
+    EXPECT_EQ(common::E_INVALID_ARG,
+              tsfile_reader_get_tsfile_properties(nullptr, &properties,
+                                                  &property_count));
+    EXPECT_EQ(nullptr, properties);
+    EXPECT_EQ(0U, property_count);
+    EXPECT_EQ(common::E_OK, tsfile_reader_close(reader));
+    EXPECT_EQ(0, std::remove(file_name));
+}
+
+TEST(CWrapperPropertiesTest, TableWriterSetterUsesExplicitLengths) {
+    const char* file_name = "cwrapper_table_properties_test.tsfile";
+    std::remove(file_name);
+
+    ERRNO error_code = common::E_OK;
+    WriteFile file = write_file_new(file_name, &error_code);
+    ASSERT_NE(nullptr, file);
+    ASSERT_EQ(common::E_OK, error_code);
+
+    ColumnSchema column = {const_cast<char*>("value"), TS_DATATYPE_INT64,
+                           FIELD};
+    TableSchema schema = {const_cast<char*>("table"), &column, 1};
+    TsFileWriter writer = tsfile_writer_new(file, &schema, &error_code);
+    ASSERT_NE(nullptr, writer);
+    const uint8_t binary[] = {0xAA, 0x00, 0xBB};
+    const uint32_t oversized_len =
+        static_cast<uint32_t>(std::numeric_limits<int32_t>::max()) + 1U;
+    EXPECT_EQ(common::E_OUT_OF_RANGE,
+              tsfile_writer_add_tsfile_property(writer, "binary", 6, binary,
+                                                oversized_len));
+    ASSERT_EQ(common::E_OK, tsfile_writer_add_tsfile_property(
+                                writer, "binary", 6, binary, sizeof(binary)));
+    ASSERT_EQ(common::E_OK, tsfile_writer_close(writer));
+    free_write_file(&file);
+
+    TsFileReader reader = tsfile_reader_new(file_name, &error_code);
+    ASSERT_NE(nullptr, reader);
+    TsFileProperty* properties = nullptr;
+    uint32_t property_count = 0;
+    ASSERT_EQ(common::E_OK, tsfile_reader_get_tsfile_properties(
+                                reader, &properties, &property_count));
+    const TsFileProperty* property =
+        FindProperty(properties, property_count, "binary");
+    ASSERT_NE(nullptr, property);
+    ASSERT_EQ(sizeof(binary), property->value_len);
+    EXPECT_EQ(0, std::memcmp(binary, property->value, sizeof(binary)));
+    tsfile_free_tsfile_properties(properties, property_count);
+    EXPECT_EQ(common::E_OK, tsfile_reader_close(reader));
+    EXPECT_EQ(0, std::remove(file_name));
+}
+
+}  // namespace
diff --git a/cpp/test/reader/bloom_filter_test.cc 
b/cpp/test/reader/bloom_filter_test.cc
index 29b24db97..46a43e466 100644
--- a/cpp/test/reader/bloom_filter_test.cc
+++ b/cpp/test/reader/bloom_filter_test.cc
@@ -64,3 +64,52 @@ TEST(BloomfilterTest, BloomFilter) {
     common::mem_free(filter_data_bytes);
     common::mem_free(filter_data_bytes2);
 }
+
+TEST(BloomfilterTest, EmptyFilterUsesJavaCompatibleEncoding) {
+    BloomFilter filter;
+    ASSERT_EQ(common::E_OK, filter.init(0.1, 0));
+
+    common::ByteStream out(1024, common::MOD_DEFAULT);
+    ASSERT_EQ(common::E_OK, filter.serialize_to(out));
+    ASSERT_EQ(1U, out.total_size());
+
+    BloomFilter deserialized;
+    ASSERT_EQ(common::E_OK, deserialized.deserialize_from(out));
+    EXPECT_TRUE(deserialized.is_empty());
+    EXPECT_EQ(0U, out.remaining_size());
+}
+
+TEST(BloomfilterTest, DeserializesLegacyEmptyFilterEncoding) {
+    common::ByteStream out(1024, common::MOD_DEFAULT);
+    ASSERT_EQ(common::E_OK, common::SerializationUtil::write_var_uint(0, out));
+    ASSERT_EQ(common::E_OK, common::SerializationUtil::write_var_uint(0, out));
+    ASSERT_EQ(common::E_OK, common::SerializationUtil::write_var_uint(0, out));
+
+    BloomFilter deserialized;
+    ASSERT_EQ(common::E_OK, deserialized.deserialize_from(out));
+    EXPECT_TRUE(deserialized.is_empty());
+    EXPECT_EQ(0U, out.remaining_size());
+}
+
+TEST(BloomfilterTest, RejectsInvalidHashFunctionCount) {
+    common::ByteStream out(1024, common::MOD_DEFAULT);
+    const uint8_t filter_byte = 1;
+    ASSERT_EQ(common::E_OK, common::SerializationUtil::write_var_uint(1, out));
+    ASSERT_EQ(common::E_OK, out.write_buf(&filter_byte, 1));
+    ASSERT_EQ(common::E_OK,
+              common::SerializationUtil::write_var_uint(256, out));
+    ASSERT_EQ(common::E_OK, common::SerializationUtil::write_var_uint(
+                                BloomFilter::MAX_HASH_FUNC_COUNT + 1, out));
+
+    BloomFilter filter;
+    EXPECT_EQ(common::E_TSFILE_CORRUPTED, filter.deserialize_from(out));
+}
+
+TEST(BloomfilterTest, RejectsFilterLengthBeyondRemainingInput) {
+    common::ByteStream out(1024, common::MOD_DEFAULT);
+    ASSERT_EQ(common::E_OK,
+              common::SerializationUtil::write_var_uint(1024, out));
+
+    BloomFilter filter;
+    EXPECT_EQ(common::E_TSFILE_CORRUPTED, filter.deserialize_from(out));
+}
diff --git a/cpp/test/writer/table_view/tsfile_writer_table_test.cc 
b/cpp/test/writer/table_view/tsfile_writer_table_test.cc
index 0dfaccc06..2dd9b5643 100644
--- a/cpp/test/writer/table_view/tsfile_writer_table_test.cc
+++ b/cpp/test/writer/table_view/tsfile_writer_table_test.cc
@@ -144,6 +144,30 @@ TEST_F(TsFileWriterTableTest, WriteTableTest) {
     delete table_schema;
 }
 
+TEST_F(TsFileWriterTableTest, AddTsFilePropertyDelegatesToWriter) {
+    auto table_schema = gen_table_schema(0);
+    TsFileTableWriter writer(&write_file_, table_schema);
+    const std::vector<uint8_t> before_flush = {'b', 'e', 'f', 'o', 'r', 'e'};
+    const std::vector<uint8_t> after_flush = {0x00, 0xFF, 0x01};
+
+    ASSERT_EQ(common::E_OK,
+              writer.add_tsfile_property("table-property", before_flush));
+    ASSERT_EQ(common::E_OK, writer.flush());
+    ASSERT_EQ(common::E_OK,
+              writer.add_tsfile_property("table-property", after_flush));
+    ASSERT_EQ(common::E_OK, writer.close());
+    ASSERT_EQ(common::E_FILE_WRITE_ERR,
+              writer.add_tsfile_property("closed", after_flush));
+
+    TsFileReader reader;
+    ASSERT_EQ(common::E_OK, reader.open(file_name_));
+    TsFileProperties properties = reader.get_tsfile_properties();
+    ASSERT_FALSE(properties.at("table-property").is_null);
+    EXPECT_EQ(after_flush, properties.at("table-property").value);
+    EXPECT_EQ(common::E_OK, reader.close());
+    delete table_schema;
+}
+
 TEST_F(TsFileWriterTableTest, WithoutTagAndMultiPage) {
     std::vector<MeasurementSchema*> measurement_schemas;
     std::vector<ColumnCategory> column_categories;
diff --git a/cpp/test/writer/tsfile_properties_test.cc 
b/cpp/test/writer/tsfile_properties_test.cc
new file mode 100644
index 000000000..4590c1bcd
--- /dev/null
+++ b/cpp/test/writer/tsfile_properties_test.cc
@@ -0,0 +1,112 @@
+/*
+ * 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.
+ */
+
+#include <gtest/gtest.h>
+
+#include <cstdio>
+#include <vector>
+
+#include "common/global.h"
+#include "reader/tsfile_reader.h"
+#include "writer/tsfile_writer.h"
+
+namespace storage {
+
+namespace {
+
+std::vector<uint8_t> Bytes(const std::string& value) {
+    return std::vector<uint8_t>(value.begin(), value.end());
+}
+
+class TsFilePropertiesTest : public ::testing::Test {
+   protected:
+    void SetUp() override {
+        libtsfile_init();
+        file_name_ = "tsfile_properties_test.tsfile";
+        std::remove(file_name_.c_str());
+    }
+
+    void TearDown() override {
+        std::remove(file_name_.c_str());
+        libtsfile_destroy();
+    }
+
+    std::string file_name_;
+};
+
+TEST_F(TsFilePropertiesTest, WriterPreservesBinaryNullAndEmptyValues) {
+    TsFileWriter writer;
+    ASSERT_EQ(common::E_OK, writer.open(file_name_));
+
+    const std::vector<uint8_t> first_value = {'f', 'i', 'r', 's', 't'};
+    ASSERT_EQ(common::E_OK,
+              writer.add_tsfile_property("overwritten", first_value));
+    ASSERT_EQ(common::E_OK, writer.flush());
+
+    const std::vector<uint8_t> binary_value = {0x00, 0x7F, 0x80, 0xFF, 0x00};
+    ASSERT_EQ(common::E_OK,
+              writer.add_tsfile_property("overwritten", binary_value));
+    std::vector<uint8_t> copied_value = {0x10, 0x20, 0x30};
+    ASSERT_EQ(common::E_OK, writer.add_tsfile_property("copied", 
copied_value));
+    copied_value[0] = 0xFF;
+    const std::string embedded_null_key("embedded\0key", 12);
+    ASSERT_EQ(common::E_OK,
+              writer.add_tsfile_property(embedded_null_key, binary_value));
+    ASSERT_EQ(common::E_OK,
+              writer.add_tsfile_property("empty", std::vector<uint8_t>()));
+    ASSERT_EQ(common::E_OK, writer.add_tsfile_property("null", nullptr, 0));
+    ASSERT_EQ(common::E_INVALID_ARG,
+              writer.add_tsfile_property("invalid", nullptr, 1));
+    ASSERT_EQ(common::E_OK,
+              writer.add_tsfile_property("encryptLevel", Bytes("custom")));
+    ASSERT_EQ(common::E_OK,
+              writer.add_tsfile_property("encryptType", Bytes("custom")));
+    ASSERT_EQ(common::E_OK,
+              writer.add_tsfile_property("encryptKey", Bytes("custom")));
+    ASSERT_EQ(common::E_OK, writer.close());
+    ASSERT_EQ(common::E_FILE_WRITE_ERR,
+              writer.add_tsfile_property("closed", binary_value));
+
+    TsFileReader reader;
+    ASSERT_EQ(common::E_OK, reader.open(file_name_));
+    TsFileProperties properties = reader.get_tsfile_properties();
+
+    ASSERT_FALSE(properties.at("overwritten").is_null);
+    EXPECT_EQ(binary_value, properties.at("overwritten").value);
+    ASSERT_FALSE(properties.at("empty").is_null);
+    EXPECT_TRUE(properties.at("empty").value.empty());
+    EXPECT_EQ((std::vector<uint8_t>{0x10, 0x20, 0x30}),
+              properties.at("copied").value);
+    EXPECT_EQ(binary_value, properties.at(embedded_null_key).value);
+    EXPECT_TRUE(properties.at("null").is_null);
+    EXPECT_TRUE(properties.at("null").value.empty());
+
+    ASSERT_FALSE(properties.at("encryptLevel").is_null);
+    EXPECT_EQ(Bytes("0"), properties.at("encryptLevel").value);
+    ASSERT_FALSE(properties.at("encryptType").is_null);
+    EXPECT_EQ(Bytes("org.apache.tsfile.encrypt.UNENCRYPTED"),
+              properties.at("encryptType").value);
+    EXPECT_TRUE(properties.at("encryptKey").is_null);
+    EXPECT_TRUE(properties.at("encryptKey").value.empty());
+    EXPECT_EQ(common::E_OK, reader.close());
+}
+
+}  // namespace
+
+}  // namespace storage
diff --git a/python/README-zh.md b/python/README-zh.md
index 660c001e8..dd3a59ea4 100644
--- a/python/README-zh.md
+++ b/python/README-zh.md
@@ -66,4 +66,20 @@ mvn -P with-cpp,with-python clean verify
 
 ```sh
 python setup.py build_ext --inplace
-```
\ No newline at end of file
+```
+
+## 文件级 Properties
+
+`TsFileWriter` 和 `TsFileTableWriter` 可以在打开期间写入二进制 property。
+setter 仅接受 `bytes`。reader 返回 `dict[str, bytes | None]`,并区分 null 与
+零长度 bytes。
+
+```python
+with TsFileWriter("example.tsfile") as writer:
+    writer.add_tsfile_property("binary-property", b"\x01\x00\xff")
+
+with TsFileReader("example.tsfile") as reader:
+    properties = reader.get_tsfile_properties()
+```
+
+Property value 不携带数据类型;保存数字或结构体时应使用明确、可跨语言的字节编码。
diff --git a/python/README.md b/python/README.md
index 51cb498ec..8e2716a2c 100644
--- a/python/README.md
+++ b/python/README.md
@@ -61,3 +61,19 @@ Build by python command:
 python setup.py build_ext --inplace
 ```
 
+## File-level properties
+
+`TsFileWriter` and `TsFileTableWriter` accept binary properties while they are
+open. The setter accepts `bytes` only. Readers return `dict[str, bytes | 
None]`,
+preserving null and empty values separately.
+
+```python
+with TsFileWriter("example.tsfile") as writer:
+    writer.add_tsfile_property("binary-property", b"\x01\x00\xff")
+
+with TsFileReader("example.tsfile") as reader:
+    properties = reader.get_tsfile_properties()
+```
+
+Values do not carry a data type; use an explicit portable encoding when storing
+numbers or structures.
diff --git a/python/tests/test_tsfile_properties.py 
b/python/tests/test_tsfile_properties.py
new file mode 100644
index 000000000..de2d8c88f
--- /dev/null
+++ b/python/tests/test_tsfile_properties.py
@@ -0,0 +1,98 @@
+# 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 pytest
+
+from tsfile import (
+    ColumnCategory,
+    ColumnSchema,
+    FileWriteError,
+    TableSchema,
+    TSDataType,
+    TsFileCorruptedError,
+    TsFileReader,
+    TsFileTableWriter,
+    TsFileWriter,
+)
+
+
+def test_tsfile_writer_properties_round_trip(tmp_path):
+    path = os.fspath(tmp_path / "writer-properties.tsfile")
+    writer = TsFileWriter(path)
+    writer.add_tsfile_property("overwritten", b"first")
+    writer.flush()
+    writer.add_tsfile_property("overwritten", b"\x00\xff\x80\x00")
+    writer.add_tsfile_property("empty", b"")
+    writer.add_tsfile_property("embedded\x00key", b"binary-key")
+
+    with pytest.raises(TypeError):
+        writer.add_tsfile_property("text", "not-bytes")
+    with pytest.raises(TypeError):
+        writer.add_tsfile_property("bytearray", bytearray(b"not-bytes"))
+    with pytest.raises(TypeError):
+        writer.add_tsfile_property("bytes-subclass", type("B", (bytes,), 
{})(b"value"))
+
+    writer.close()
+    with pytest.raises(FileWriteError):
+        writer.add_tsfile_property("closed", b"value")
+
+    with TsFileReader(path) as reader:
+        properties = reader.get_tsfile_properties()
+        assert properties["overwritten"] == b"\x00\xff\x80\x00"
+        assert properties["empty"] == b""
+        assert properties["embedded\x00key"] == b"binary-key"
+        assert properties["encryptLevel"] == b"0"
+        assert properties["encryptType"] == 
b"org.apache.tsfile.encrypt.UNENCRYPTED"
+        assert properties["encryptKey"] is None
+
+
+def test_tsfile_table_writer_property_delegation(tmp_path):
+    path = os.fspath(tmp_path / "table-writer-properties.tsfile")
+    schema = TableSchema(
+        "table",
+        [ColumnSchema("value", TSDataType.INT64, ColumnCategory.FIELD)],
+    )
+    writer = TsFileTableWriter(path, schema)
+    writer.add_tsfile_property("table-property", b"before")
+    writer.flush()
+    writer.add_tsfile_property("table-property", b"after\x00\xff")
+    writer.close()
+    with pytest.raises(FileWriteError):
+        writer.add_tsfile_property("closed", b"value")
+
+    with TsFileReader(path) as reader:
+        assert reader.get_tsfile_properties()["table-property"] == 
b"after\x00\xff"
+
+
+def test_reader_reports_invalid_utf8_property_key(tmp_path):
+    path = tmp_path / "invalid-property-key.tsfile"
+    writer = TsFileWriter(os.fspath(path))
+    writer.add_tsfile_property("invalid-key", b"value")
+    writer.close()
+
+    file_bytes = path.read_bytes()
+    assert file_bytes.count(b"invalid-key") == 1
+    path.write_bytes(file_bytes.replace(b"invalid-key", b"invalid-\xffey", 1))
+
+    with TsFileReader(os.fspath(path)) as reader:
+        with pytest.raises(
+            TsFileCorruptedError,
+            match="TsFile property key is not valid UTF-8",
+        ):
+            reader.get_tsfile_properties()
diff --git a/python/tsfile/tsfile_cpp.pxd b/python/tsfile/tsfile_cpp.pxd
index cc14f4034..4daf0b153 100644
--- a/python/tsfile/tsfile_cpp.pxd
+++ b/python/tsfile/tsfile_cpp.pxd
@@ -25,6 +25,7 @@ cdef extern from "cwrapper/errno_define_c.h":
     enum:
         RET_OK
         RET_NO_MORE_DATA
+        RET_FILE_WRITE_ERR
 
 # import symbols from tsfile_cwrapper.h
 cdef extern from "cwrapper/tsfile_cwrapper.h":
@@ -181,6 +182,13 @@ cdef extern from "cwrapper/tsfile_cwrapper.h":
         DeviceTimeseriesMetadataEntry * entries
         uint32_t device_count
 
+    ctypedef struct TsFileProperty:
+        char * key
+        uint32_t key_len
+        uint8_t * value
+        uint32_t value_len
+        bint is_null
+
     ctypedef struct ResultSetMetaData:
         char** column_names
         TSDataType * data_types
@@ -201,6 +209,9 @@ cdef extern from "cwrapper/tsfile_cwrapper.h":
 
     # writer : flush
     ErrorCode _tsfile_writer_flush(TsFileWriter writer);
+    ErrorCode _tsfile_writer_add_tsfile_property(
+        TsFileWriter writer, const char * key, uint32_t key_len,
+        const uint8_t * value, uint32_t value_len);
 
     # writer : register table, device and timeseries
     ErrorCode _tsfile_writer_register_table(TsFileWriter writer, TableSchema * 
schema);
@@ -316,6 +327,11 @@ cdef extern from "cwrapper/tsfile_cwrapper.h":
         DeviceTimeseriesMetadataMap * out_map);
     void tsfile_free_device_timeseries_metadata_map(
         DeviceTimeseriesMetadataMap * map);
+    ErrorCode tsfile_reader_get_tsfile_properties(
+        TsFileReader reader, TsFileProperty ** out_properties,
+        uint32_t * out_length);
+    void tsfile_free_tsfile_properties(TsFileProperty * properties,
+                                       uint32_t length);
 
     # Tag filter types and functions
 
diff --git a/python/tsfile/tsfile_reader.pyx b/python/tsfile/tsfile_reader.pyx
index 36374adde..a2e8fe263 100644
--- a/python/tsfile/tsfile_reader.pyx
+++ b/python/tsfile/tsfile_reader.pyx
@@ -26,10 +26,11 @@ from libc.string cimport strlen
 from cpython.bytes cimport PyBytes_FromStringAndSize
 from libc.string cimport memset
 import pyarrow as pa
-from libc.stdint cimport INT64_MIN, INT64_MAX, uintptr_t
+from libc.stdint cimport INT64_MIN, INT64_MAX, uint32_t, uintptr_t
 
 from tsfile.schema import TSDataType as TSDataTypePy
 from tsfile.schema import DeviceID, DeviceTimeseriesMetadataGroup
+from tsfile.exceptions import TsFileCorruptedError
 from tsfile.tag_filter import ComparisonTagFilter, BetweenTagFilter, 
AndTagFilter, OrTagFilter, NotTagFilter
 from .date_utils import parse_int_to_date
 from .tsfile_cpp cimport *
@@ -518,6 +519,45 @@ cdef class TsFileReaderPy:
         """
         return reader_get_timeseries_metadata_c(self.reader, device_ids)
 
+    def get_tsfile_properties(self) -> Dict[str, Optional[bytes]]:
+        """
+        Return file-level properties as ``dict[str, bytes | None]``.
+
+        Null property values are returned as ``None`` and remain distinct from
+        non-null zero-length byte strings.
+        """
+        cdef TsFileProperty * properties = NULL
+        cdef uint32_t property_count = 0
+        cdef uint32_t i
+        cdef ErrorCode err_code
+        cdef object key
+        cdef dict result = {}
+
+        err_code = tsfile_reader_get_tsfile_properties(
+            self.reader, &properties, &property_count
+        )
+        check_error(err_code)
+        try:
+            for i in range(property_count):
+                try:
+                    key = PyBytes_FromStringAndSize(
+                        properties[i].key, properties[i].key_len
+                    ).decode('utf-8')
+                except UnicodeDecodeError:
+                    raise TsFileCorruptedError(
+                        context="TsFile property key is not valid UTF-8"
+                    ) from None
+                if properties[i].is_null:
+                    result[key] = None
+                else:
+                    result[key] = PyBytes_FromStringAndSize(
+                        <const char *> properties[i].value,
+                        properties[i].value_len,
+                    )
+        finally:
+            tsfile_free_tsfile_properties(properties, property_count)
+        return result
+
     def close(self):
         """
         Close TsFile Reader, if reader has result sets, invalid them.
diff --git a/python/tsfile/tsfile_table_writer.py 
b/python/tsfile/tsfile_table_writer.py
index 9f3a257e6..e4fceef97 100644
--- a/python/tsfile/tsfile_table_writer.py
+++ b/python/tsfile/tsfile_table_writer.py
@@ -232,6 +232,12 @@ class TsFileTableWriter:
         """
         self.writer.close()
 
+    def add_tsfile_property(self, key: str, value: bytes):
+        """
+        Add or replace a binary file-level property while the writer is open.
+        """
+        self.writer.add_tsfile_property(key, value)
+
     def flush(self):
         """
         Flush current data to tsfile.
diff --git a/python/tsfile/tsfile_writer.pyx b/python/tsfile/tsfile_writer.pyx
index 9e84d83c0..4df03b449 100644
--- a/python/tsfile/tsfile_writer.pyx
+++ b/python/tsfile/tsfile_writer.pyx
@@ -23,7 +23,8 @@ from tsfile.schema import TableSchema as TableSchemaPy
 from tsfile.schema import TimeseriesSchema as TimeseriesSchemaPy, DeviceSchema 
as DeviceSchemaPy
 from tsfile.tablet import Tablet as TabletPy
 from libc.string cimport memset
-from libc.stdint cimport uintptr_t
+from libc.stdint cimport uint32_t, uint8_t, uintptr_t
+from cpython.bytes cimport PyBytes_AsStringAndSize
 from .tsfile_cpp cimport *
 from .tsfile_py_cpp cimport *
 
@@ -163,6 +164,40 @@ cdef class TsFileWriterPy:
             if arrow_schema.release != NULL:
                 arrow_schema.release(&arrow_schema)
 
+    def add_tsfile_property(self, key: str, value: bytes):
+        """
+        Add or replace a binary file-level property while the writer is open.
+
+        ``value`` must be ``bytes``. The data is copied immediately, and a
+        later call with the same key replaces the previous value.
+        """
+        if not isinstance(key, str):
+            raise TypeError("TsFile property key must be str")
+        if type(value) is not bytes:
+            raise TypeError("TsFile property value must be bytes")
+        if self.writer == NULL:
+            check_error(RET_FILE_WRITE_ERR, b"TsFile writer is closed")
+
+        cdef bytes encoded_key = key.encode('utf-8')
+        cdef char * value_ptr = NULL
+        cdef Py_ssize_t value_len = 0
+        cdef ErrorCode errno
+        if len(encoded_key) > 0x7FFFFFFF:
+            raise OverflowError("TsFile property key is too large")
+        if PyBytes_AsStringAndSize(value, &value_ptr, &value_len) < 0:
+            raise TypeError("TsFile property value must be bytes")
+        if value_len > 0x7FFFFFFF:
+            raise OverflowError("TsFile property value is too large")
+
+        errno = _tsfile_writer_add_tsfile_property(
+            self.writer,
+            <const char *> encoded_key,
+            <uint32_t> len(encoded_key),
+            <const uint8_t *> value_ptr,
+            <uint32_t> value_len,
+        )
+        check_error(errno)
+
     cpdef close(self):
         """
         Flush data and Close tsfile writer.

Reply via email to