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

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


The following commit(s) were added to refs/heads/master by this push:
     new ad7fcac11f9 fix(export): stamp ZIP entries with the export time 
(#44391)
ad7fcac11f9 is described below

commit ad7fcac11f9b5b4cd6ee455df1afa60849addebc
Author: Devansh Khetan <[email protected]>
AuthorDate: Thu Sep 24 19:35:39 2026 +0530

    fix(export): stamp ZIP entries with the export time (#44391)
---
 superset/charts/api.py                     |  7 +--
 superset/cli/importexport.py               |  8 ++-
 superset/dashboards/api.py                 |  6 ++-
 superset/databases/api.py                  |  6 ++-
 superset/datasets/api.py                   | 11 ++--
 superset/importexport/api.py               |  9 ++--
 superset/queries/saved_queries/api.py      |  7 +--
 superset/themes/api.py                     |  7 +--
 superset/utils/core.py                     | 27 ++++++++--
 tests/unit_tests/utils/create_zip_tests.py | 84 ++++++++++++++++++++++++++++++
 10 files changed, 144 insertions(+), 28 deletions(-)

diff --git a/superset/charts/api.py b/superset/charts/api.py
index 64f7ef3dbc6..fdcc0d6a2d8 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -122,7 +122,7 @@ from superset.subjects.filters import (
 from superset.tasks.thumbnails import cache_chart_thumbnail
 from superset.tasks.utils import get_current_user
 from superset.utils import json
-from superset.utils.core import send_export_zip
+from superset.utils.core import send_export_zip, write_zip_entry
 from superset.utils.screenshots import (
     ChartScreenshot,
     DEFAULT_CHART_WINDOW_SIZE,
@@ -1419,8 +1419,9 @@ class ChartRestApi(SoftDeleteApiMixin, 
BaseSupersetModelRestApi):
         with ZipFile(buf, "w") as bundle:
             try:
                 for file_name, file_content in 
ExportChartsCommand(requested_ids).run():
-                    with bundle.open(f"{root}/{file_name}", "w") as fp:
-                        fp.write(file_content().encode())
+                    write_zip_entry(
+                        bundle, f"{root}/{file_name}", file_content().encode()
+                    )
             except ChartNotFoundError:
                 return self.response_404()
         buf.seek(0)
diff --git a/superset/cli/importexport.py b/superset/cli/importexport.py
index f271e9318f0..bffa2961242 100755
--- a/superset/cli/importexport.py
+++ b/superset/cli/importexport.py
@@ -28,7 +28,7 @@ from flask.cli import with_appcontext
 
 from superset import security_manager
 from superset.extensions import db
-from superset.utils.core import override_user
+from superset.utils.core import override_user, write_zip_entry
 
 logger = logging.getLogger(__name__)
 
@@ -105,8 +105,7 @@ def export_dashboards(dashboard_file: Optional[str] = None) 
-> None:
     try:
         with ZipFile(dashboard_file, "w") as bundle:
             for file_name, file_content in 
ExportDashboardsCommand(dashboard_ids).run():
-                with bundle.open(f"{root}/{file_name}", "w") as fp:
-                    fp.write(file_content().encode())
+                write_zip_entry(bundle, f"{root}/{file_name}", 
file_content().encode())
     except Exception:  # pylint: disable=broad-except
         logger.exception(
             "There was an error when exporting the dashboards, please check "
@@ -138,8 +137,7 @@ def export_datasources(datasource_file: Optional[str] = 
None) -> None:
     try:
         with ZipFile(datasource_file, "w") as bundle:
             for file_name, file_content in 
ExportDatasetsCommand(dataset_ids).run():
-                with bundle.open(f"{root}/{file_name}", "w") as fp:
-                    fp.write(file_content().encode())
+                write_zip_entry(bundle, f"{root}/{file_name}", 
file_content().encode())
     except Exception:  # pylint: disable=broad-except
         logger.exception(
             "There was an error when exporting the datasets, please check "
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 45499da0e9b..c12ed00661c 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -189,6 +189,7 @@ from superset.utils.core import (
     get_user_id,
     parse_boolean_string,
     send_export_zip,
+    write_zip_entry,
 )
 from superset.utils.file import get_filename
 from superset.utils.pdf import build_pdf_from_screenshots
@@ -1701,8 +1702,9 @@ class DashboardRestApi(
                 for file_name, file_content in ExportDashboardsCommand(
                     requested_ids
                 ).run():
-                    with bundle.open(f"{root}/{file_name}", "w") as fp:
-                        fp.write(file_content().encode())
+                    write_zip_entry(
+                        bundle, f"{root}/{file_name}", file_content().encode()
+                    )
             except DashboardNotFoundError:
                 return self.response_404()
         buf.seek(0)
diff --git a/superset/databases/api.py b/superset/databases/api.py
index da34fc21572..be972fb38f7 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -131,6 +131,7 @@ from superset.utils.core import (
     get_username,
     parse_js_uri_path_item,
     send_export_zip,
+    write_zip_entry,
 )
 from superset.utils.decorators import transaction
 from superset.utils.oauth2 import decode_oauth2_state
@@ -1565,8 +1566,9 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
                 for file_name, file_content in ExportDatabasesCommand(
                     requested_ids
                 ).run():
-                    with bundle.open(f"{root}/{file_name}", "w") as fp:
-                        fp.write(file_content().encode())
+                    write_zip_entry(
+                        bundle, f"{root}/{file_name}", file_content().encode()
+                    )
             except DatabaseNotFoundError:
                 return self.response_404()
         buf.seek(0)
diff --git a/superset/datasets/api.py b/superset/datasets/api.py
index 6bed4223aaf..4c84e41a2e7 100644
--- a/superset/datasets/api.py
+++ b/superset/datasets/api.py
@@ -108,7 +108,11 @@ from superset.exceptions import (
 from superset.jinja_context import BaseTemplateProcessor, 
get_template_processor
 from superset.subjects.filters import FilterRelatedSubjects, 
subject_type_filter
 from superset.utils import json
-from superset.utils.core import parse_boolean_string, send_export_zip
+from superset.utils.core import (
+    parse_boolean_string,
+    send_export_zip,
+    write_zip_entry,
+)
 from superset.versioning.api_helpers import (
     concurrency_token_from,
     current_entity_version_info,
@@ -1018,8 +1022,9 @@ class DatasetRestApi(SoftDeleteApiMixin, 
BaseSupersetModelRestApi):
                 for file_name, file_content in ExportDatasetsCommand(
                     requested_ids
                 ).run():
-                    with bundle.open(f"{root}/{file_name}", "w") as fp:
-                        fp.write(file_content().encode())
+                    write_zip_entry(
+                        bundle, f"{root}/{file_name}", file_content().encode()
+                    )
             except DatasetNotFoundError:
                 return self.response_404()
         buf.seek(0)
diff --git a/superset/importexport/api.py b/superset/importexport/api.py
index 066915f9411..edffa8979df 100644
--- a/superset/importexport/api.py
+++ b/superset/importexport/api.py
@@ -30,7 +30,11 @@ from superset.commands.importers.v1.assets import 
ImportAssetsCommand
 from superset.commands.importers.v1.utils import get_contents_from_bundle
 from superset.extensions import event_logger
 from superset.utils import json
-from superset.utils.core import parse_boolean_string, send_export_zip
+from superset.utils.core import (
+    parse_boolean_string,
+    send_export_zip,
+    write_zip_entry,
+)
 from superset.views.base_api import BaseSupersetApi, requires_form_data, 
statsd_metrics
 
 
@@ -80,8 +84,7 @@ class ImportExportRestApi(BaseSupersetApi):
         buf = BytesIO()
         with ZipFile(buf, "w") as bundle:
             for file_name, file_content in ExportAssetsCommand().run():
-                with bundle.open(f"{root}/{file_name}", "w") as fp:
-                    fp.write(file_content().encode())
+                write_zip_entry(bundle, f"{root}/{file_name}", 
file_content().encode())
         buf.seek(0)
 
         return send_export_zip(buf, filename)
diff --git a/superset/queries/saved_queries/api.py 
b/superset/queries/saved_queries/api.py
index 971f2300c60..43090d64a04 100644
--- a/superset/queries/saved_queries/api.py
+++ b/superset/queries/saved_queries/api.py
@@ -55,7 +55,7 @@ from superset.queries.saved_queries.schemas import (
     validate_label,
 )
 from superset.utils import json
-from superset.utils.core import send_export_zip
+from superset.utils.core import send_export_zip, write_zip_entry
 from superset.views.base_api import (
     BaseSupersetModelRestApi,
     RelatedFieldFilter,
@@ -301,8 +301,9 @@ class SavedQueryRestApi(BaseSupersetModelRestApi):
                 for file_name, file_content in ExportSavedQueriesCommand(
                     requested_ids
                 ).run():
-                    with bundle.open(f"{root}/{file_name}", "w") as fp:
-                        fp.write(file_content().encode())
+                    write_zip_entry(
+                        bundle, f"{root}/{file_name}", file_content().encode()
+                    )
             except SavedQueryNotFoundError:
                 return self.response_404()
         buf.seek(0)
diff --git a/superset/themes/api.py b/superset/themes/api.py
index 2d2a7628acd..9b829b558a5 100644
--- a/superset/themes/api.py
+++ b/superset/themes/api.py
@@ -65,7 +65,7 @@ from superset.themes.schemas import (
     ThemePostSchema,
     ThemePutSchema,
 )
-from superset.utils.core import send_export_zip
+from superset.utils.core import send_export_zip, write_zip_entry
 from superset.views.base_api import (
     BaseSupersetModelRestApi,
     RelatedFieldFilter,
@@ -529,8 +529,9 @@ class ThemeRestApi(BaseSupersetModelRestApi):
         with ZipFile(buf, "w") as bundle:
             try:
                 for file_name, file_content in 
ExportThemesCommand(requested_ids).run():
-                    with bundle.open(f"{root}/{file_name}", "w") as fp:
-                        fp.write(file_content().encode())
+                    write_zip_entry(
+                        bundle, f"{root}/{file_name}", file_content().encode()
+                    )
             except ThemeNotFoundError:
                 return self.response_404()
         buf.seek(0)
diff --git a/superset/utils/core.py b/superset/utils/core.py
index 63c8ccc69d9..ba059d9905d 100644
--- a/superset/utils/core.py
+++ b/superset/utils/core.py
@@ -39,7 +39,7 @@ import zlib
 from collections.abc import Collection, Iterable, Iterator, Sequence
 from contextlib import closing, contextmanager
 from dataclasses import dataclass
-from datetime import timedelta
+from datetime import datetime, timedelta
 from email.mime.application import MIMEApplication
 from email.mime.image import MIMEImage
 from email.mime.multipart import MIMEMultipart
@@ -60,7 +60,7 @@ from typing import (
     TypeVar,
 )
 from urllib.parse import unquote_plus, urlparse
-from zipfile import ZipFile
+from zipfile import ZipFile, ZipInfo
 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 
 import markdown as md
@@ -2222,12 +2222,31 @@ def apply_max_row_limit(
     return max_limit
 
 
+def write_zip_entry(bundle: ZipFile, filename: str, contents: bytes) -> None:
+    """Add a file to an open ZIP bundle, stamped with the current local time.
+
+    ``ZipFile.open(name, "w")`` falls back to the 1980-01-01 DOS epoch, which
+    extractors surface as a bogus (Windows Explorer) or empty (7-Zip)
+    modification date on every extracted file. Passing an explicit ``ZipInfo``
+    gives the entry the time the export was generated instead.
+    """
+    info = ZipInfo(filename=filename, date_time=datetime.now().timetuple()[:6])
+    # A pre-built ZipInfo bypasses the bundle's own compression settings, which
+    # zipfile only copies onto entries it creates from a plain filename, so 
pass
+    # both through explicitly.
+    bundle.writestr(
+        info,
+        contents,
+        compress_type=bundle.compression,
+        compresslevel=bundle.compresslevel,
+    )
+
+
 def create_zip(files: dict[str, Any]) -> BytesIO:
     buf = BytesIO()
     with ZipFile(buf, "w") as bundle:
         for filename, contents in files.items():
-            with bundle.open(filename, "w") as fp:
-                fp.write(contents)
+            write_zip_entry(bundle, filename, contents)
     buf.seek(0)
     return buf
 
diff --git a/tests/unit_tests/utils/create_zip_tests.py 
b/tests/unit_tests/utils/create_zip_tests.py
new file mode 100644
index 00000000000..6db613d0ffc
--- /dev/null
+++ b/tests/unit_tests/utils/create_zip_tests.py
@@ -0,0 +1,84 @@
+# 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.
+
+from datetime import datetime, timedelta
+from io import BytesIO
+from zipfile import ZIP_DEFLATED, ZipFile
+
+from superset.utils.core import create_zip, write_zip_entry
+
+DOS_EPOCH = (1980, 1, 1, 0, 0, 0)
+
+
+def test_create_zip_stamps_entries_with_the_current_time() -> None:
+    """
+    Entries must carry a real modification date.
+
+    Without an explicit ZipInfo, zipfile falls back to the 1980-01-01 DOS 
epoch,
+    which extractors show as a bogus or empty modification date on every
+    extracted file (#44388).
+    """
+    # DOS timestamps have a two-second resolution, so an entry can be stamped
+    # slightly before the moment the archive was built.
+    before = datetime.now() - timedelta(seconds=2)
+    archive = create_zip({"query_1.csv": b"value\n1\n", "query_2.csv": 
b"value\n2\n"})
+    after = datetime.now()
+
+    with ZipFile(archive) as bundle:
+        infos = bundle.infolist()
+
+    assert [info.filename for info in infos] == ["query_1.csv", "query_2.csv"]
+    for info in infos:
+        assert info.date_time != DOS_EPOCH
+        assert before <= datetime(*info.date_time) <= after
+
+
+def test_write_zip_entry_writes_nested_paths_intact() -> None:
+    buf = BytesIO()
+    with ZipFile(buf, "w") as bundle:
+        write_zip_entry(bundle, "root/metadata.yaml", b"version: 1.0.0")
+
+    with ZipFile(buf) as bundle:
+        info = bundle.getinfo("root/metadata.yaml")
+        assert bundle.read("root/metadata.yaml") == b"version: 1.0.0"
+
+    assert info.date_time != DOS_EPOCH
+
+
+def test_write_zip_entry_honors_the_bundle_compression_settings() -> None:
+    """
+    A pre-built ZipInfo bypasses the settings zipfile copies onto entries it
+    creates itself, so the helper has to forward both of them.
+    """
+    payload = b"superset " * 2000
+
+    def build(compresslevel: int) -> bytes:
+        buf = BytesIO()
+        with ZipFile(
+            buf, "w", compression=ZIP_DEFLATED, compresslevel=compresslevel
+        ) as bundle:
+            write_zip_entry(bundle, "data.csv", payload)
+        return buf.getvalue()
+
+    fastest, smallest = build(1), build(9)
+
+    with ZipFile(BytesIO(smallest)) as bundle:
+        info = bundle.getinfo("data.csv")
+        assert info.compress_type == ZIP_DEFLATED
+        assert bundle.read("data.csv") == payload
+
+    assert len(smallest) < len(fastest)

Reply via email to